Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
384 changes: 338 additions & 46 deletions R/OHDSIAssistant/R/strategus_cohort_methods_shell.R

Large diffs are not rendered by default.

318 changes: 284 additions & 34 deletions acp_agent/study_agent_acp/agent.py

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions acp_agent/study_agent_acp/demo_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ class DemoSession:
last_keeper_concepts: Optional[Path] = None
last_keeper_review: Optional[Path] = None
last_phenotype_name: str = ""
current_study_intent: str = ""
current_workflow_type: str = ""
current_step: str = ""
current_role: str = ""
current_context: Dict[str, Any] = field(default_factory=dict)


def _extract_nested(payload: Dict[str, Any], *keys: str) -> Any:
Expand Down Expand Up @@ -247,6 +252,7 @@ def handle_line(self, line: str) -> bool:
handler = {
"/phenotype-intent-split": self._handle_intent_split,
"/phenotype-recommend": self._handle_recommend,
"/ohdsi": self._handle_workflow_dialogue,
"/vocab-search-standard": self._handle_vocab_search,
"/vocab-phoebe-related": self._handle_phoebe_related,
"/keeper-generate-concepts": self._handle_keeper_generate_concepts,
Expand All @@ -271,6 +277,11 @@ def _build_parsers(self) -> Dict[str, ShellArgumentParser]:
"Recommend phenotype candidates for a study intent.",
self._configure_recommend_parser,
),
"/ohdsi": _build_parser(
"/ohdsi",
"Ask a contextual workflow question using the current session state.",
lambda parser: parser.add_argument("text", nargs=argparse.REMAINDER),
),
"/vocab-search-standard": _build_parser(
"/vocab-search-standard",
"Search standard OMOP concepts for one or more semicolon-separated terms.",
Expand Down Expand Up @@ -363,6 +374,7 @@ def _print_help(self) -> None:
print("Commands:")
print("/phenotype-intent-split <study intent>")
print("/phenotype-recommend [--top-k N] [--max-results N] [--candidate-limit N] <study intent>")
print("/ohdsi <question about the current workflow context>")
print("/vocab-search-standard [--domains CSV] [--classes CSV] [--limit N] [--provider NAME] <term1 ; term2>")
print("/vocab-phoebe-related [--relationships CSV] [--provider NAME] <concept_id1,concept_id2>")
print("/keeper-generate-concepts [--domains CSV] [--candidate-limit N] [--min-record-count N] [--vocab-provider NAME] [--phoebe-provider NAME] [--output PATH] <phenotype>")
Expand Down Expand Up @@ -430,6 +442,14 @@ def _handle_intent_split(self, argv: Sequence[str]) -> None:
print("questions:")
for question in questions:
print(f"- {question}")
self.session.current_study_intent = study_intent
self.session.current_workflow_type = "phenotype"
self.session.current_step = "intent_split"
self.session.current_role = ""
self.session.current_context = {
"target_statement": split.get("target_statement", ""),
"outcome_statement": split.get("outcome_statement", ""),
}
self._print_llm_summary(result)
print(f"saved: {artifact}")

Expand Down Expand Up @@ -459,9 +479,46 @@ def _handle_recommend(self, argv: Sequence[str]) -> None:
print(f"{idx}. phenotype_id={phenotype_id} name={phenotype_name}")
if reasoning:
print(f" {reasoning}")
self.session.current_study_intent = study_intent
self.session.current_workflow_type = "phenotype"
self.session.current_step = "phenotype_recommendation"
self.session.current_role = ""
self.session.current_context = {
"top_k": args.top_k,
"max_results": args.max_results,
"candidate_limit": args.candidate_limit,
"recommendation_count": len(recommendations),
}
self._print_llm_summary(result)
print(f"saved: {artifact}")

def _handle_workflow_dialogue(self, argv: Sequence[str]) -> None:
args = self._parse("/ohdsi", argv)
user_prompt = " ".join(args.text).strip()
if not user_prompt:
raise ValueError("missing dialogue question")
payload = {
"user_prompt": user_prompt,
"study_intent": self.session.current_study_intent,
"workflow_type": self.session.current_workflow_type,
"current_step": self.session.current_step,
"current_role": self.session.current_role,
"current_context": self.session.current_context,
}
result = self._post_flow("/flows/workflow_context_dialogue", payload)
self._require_ok(result)
dialogue = result.get("dialogue") or {}
print(f"status: {result.get('status')}")
print(dialogue.get("answer", ""))
for label, key in (("current step guidance", "current_step_guidance"), ("cautions", "cautions"), ("suggested next actions", "suggested_next_actions")):
items = dialogue.get(key) or []
if not items:
continue
print(f"{label}:")
for item in items:
print(f"- {item}")
self._print_llm_summary(result)

def _handle_vocab_search(self, argv: Sequence[str]) -> None:
args = self._parse("/vocab-search-standard", argv)
raw_query_text = " ".join(args.queries).strip()
Expand Down
42 changes: 42 additions & 0 deletions acp_agent/study_agent_acp/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,48 @@ def build_recommendation_intent_facets_prompt(
return "\n\n".join([s for s in sections if s])


def build_workflow_context_dialogue_prompt(
overview: str,
spec: str,
output_schema: Dict[str, Any],
user_prompt: str,
study_intent: str = "",
workflow_type: str = "",
current_step: str = "",
current_role: str = "",
current_context: Optional[Dict[str, Any]] = None,
) -> str:
dynamic = {
"task": "workflow_context_dialogue",
"user_prompt": user_prompt,
"study_intent": study_intent,
"workflow_type": workflow_type,
"current_step": current_step,
"current_role": current_role,
"current_context": current_context or {},
}
strict_rules = "\n\n".join(
[
"STRICT OUTPUT RULES:",
spec,
"Return exactly ONE JSON object that matches the output schema.",
"Do NOT wrap output in markdown, code fences, or prose.",
"If uncertain, return required keys with empty arrays/strings.",
"Keep output under 10 KB.",
]
)
sections = [
overview,
"OUTPUT SCHEMA (JSON):",
json.dumps(output_schema, ensure_ascii=True),
"Below is dynamic content to analyze. Do not act until after STRICT OUTPUT RULES.",
"DYNAMIC INPUT (JSON):",
json.dumps(dynamic, ensure_ascii=True),
strict_rules,
]
return "\n\n".join([s for s in sections if s])


def build_advice_prompt(
overview: str,
spec: str,
Expand Down
41 changes: 41 additions & 0 deletions acp_agent/study_agent_acp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
{"name": "phenotype_intent_split", "endpoint": "/flows/phenotype_intent_split"},
{"name": "cohort_methods_intent_split", "endpoint": "/flows/cohort_methods_intent_split"},
{"name": "cohort_methods_specifications_recommendation", "endpoint": "/flows/cohort_methods_specifications_recommendation"},
{"name": "workflow_context_dialogue", "endpoint": "/flows/workflow_context_dialogue"},
]
SERVICE_REGISTRY_PATH = os.getenv("STUDY_AGENT_SERVICE_REGISTRY", "docs/SERVICE_REGISTRY.yaml")
logger = logging.getLogger("study_agent.acp")
Expand Down Expand Up @@ -284,13 +285,21 @@ def do_POST(self) -> None:
candidate_offset = body.get("candidate_offset")
if candidate_offset is not None:
candidate_offset = int(candidate_offset)
recommendation_role = str(body.get("recommendation_role") or "").strip() or None
workflow_type = str(body.get("workflow_type") or "").strip() or None
exclude_metadata = body.get("exclude_metadata")
if not isinstance(exclude_metadata, dict):
exclude_metadata = None
try:
result = self.agent.run_phenotype_recommendation_flow(
study_intent=study_intent,
top_k=top_k,
max_results=max_results,
candidate_limit=candidate_limit,
candidate_offset=candidate_offset,
recommendation_role=recommendation_role,
workflow_type=workflow_type,
exclude_metadata=exclude_metadata,
)
except Exception as exc:
if self.debug:
Expand All @@ -301,6 +310,38 @@ def do_POST(self) -> None:
_write_json(self, status, result)
return

if self.path == "/flows/workflow_context_dialogue":
try:
body = _read_json(self)
except Exception as exc:
_write_json(self, 400, {"error": f"invalid_json: {exc}"})
return
user_prompt = str(body.get("user_prompt") or body.get("prompt") or "").strip()
study_intent = str(body.get("study_intent") or "").strip()
workflow_type = str(body.get("workflow_type") or "").strip()
current_step = str(body.get("current_step") or "").strip()
current_role = str(body.get("current_role") or "").strip()
current_context = body.get("current_context")
if not isinstance(current_context, dict):
current_context = {}
try:
result = self.agent.run_workflow_context_dialogue_flow(
user_prompt=user_prompt,
study_intent=study_intent,
workflow_type=workflow_type,
current_step=current_step,
current_role=current_role,
current_context=current_context,
)
except Exception as exc:
if self.debug:
logger.exception("flow_failed name=workflow_context_dialogue")
_write_json(self, 500, {"error": "flow_failed", "detail": str(exc) if self.debug else None})
return
status = 200 if result.get("status") != "error" else 500
_write_json(self, status, result)
return

if self.path == "/flows/cohort_methods_specifications_recommendation":
try:
body = _read_json(self)
Expand Down
19 changes: 19 additions & 0 deletions core/study_agent_core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ class CohortMethodsIntentSplitInput(BaseModel):
llm_result: Optional[Dict[str, Any]] = None


class WorkflowContextDialogueInput(BaseModel):
user_prompt: str
study_intent: str = ""
workflow_type: str = ""
current_step: str = ""
current_role: str = ""
current_context: Dict[str, Any] = Field(default_factory=dict)
llm_result: Optional[Dict[str, Any]] = None


class PhenotypeValidationReviewInput(BaseModel):
disease_name: str = ""
keeper_row: Dict[str, Any] = Field(default_factory=dict)
Expand Down Expand Up @@ -276,6 +286,15 @@ class CohortMethodsIntentSplitOutput(BaseModel):
mode: str


class WorkflowContextDialogueOutput(BaseModel):
plan: str
answer: str
current_step_guidance: List[str] = Field(default_factory=list)
cautions: List[str] = Field(default_factory=list)
suggested_next_actions: List[str] = Field(default_factory=list)
mode: str


class PhenotypeValidationReviewOutput(BaseModel):
label: str
rationale: str
Expand Down
61 changes: 61 additions & 0 deletions core/study_agent_core/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
PhenotypeValidationReviewOutput,
PhenotypeRecommendationsInput,
PhenotypeRecommendationsOutput,
WorkflowContextDialogueInput,
WorkflowContextDialogueOutput,
)


Expand Down Expand Up @@ -599,6 +601,65 @@ def phenotype_recommendation_advice(
return _model_dump(output)


def workflow_context_dialogue(
user_prompt: str,
study_intent: str = "",
workflow_type: str = "",
current_step: str = "",
current_role: str = "",
current_context: Optional[Dict[str, Any]] = None,
llm_result: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
payload = WorkflowContextDialogueInput(
user_prompt=user_prompt,
study_intent=study_intent,
workflow_type=workflow_type,
current_step=current_step,
current_role=current_role,
current_context=current_context or {},
llm_result=llm_result,
)

plan = "Answer the user's workflow question in the context of the current study-design step."
answer = ""
current_step_guidance: List[str] = []
cautions: List[str] = []
suggested_next_actions: List[str] = []
mode = "llm"

if payload.llm_result:
if payload.llm_result.get("plan"):
plan = str(payload.llm_result["plan"])
answer = str(payload.llm_result.get("answer") or "")
if isinstance(payload.llm_result.get("current_step_guidance"), list):
current_step_guidance = [str(item) for item in payload.llm_result["current_step_guidance"]]
if isinstance(payload.llm_result.get("cautions"), list):
cautions = [str(item) for item in payload.llm_result["cautions"]]
if isinstance(payload.llm_result.get("suggested_next_actions"), list):
suggested_next_actions = [str(item) for item in payload.llm_result["suggested_next_actions"]]
else:
mode = "stub"
answer = "No LLM response is available for workflow guidance right now."
if payload.current_step:
current_step_guidance = [
f"Continue the current workflow step ({payload.current_step}) after clarifying the question manually."
]
suggested_next_actions = [
"Restate the question with more concrete study-design detail.",
"Continue the workflow and revisit the question after gathering more context.",
]

output = WorkflowContextDialogueOutput(
plan=plan,
answer=answer,
current_step_guidance=current_step_guidance,
cautions=cautions,
suggested_next_actions=suggested_next_actions,
mode=mode,
)
return _model_dump(output)


def phenotype_intent_split(
study_intent: str,
llm_result: Optional[Dict[str, Any]] = None,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "workflow_context_dialogue_output",
"type": "object",
"properties": {
"plan": {
"type": "string"
},
"answer": {
"type": "string"
},
"current_step_guidance": {
"type": "array",
"items": {
"type": "string"
}
},
"cautions": {
"type": "array",
"items": {
"type": "string"
}
},
"suggested_next_actions": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"plan",
"answer",
"current_step_guidance",
"cautions",
"suggested_next_actions"
],
"additionalProperties": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
You are the OHDSI Assistant (ACP Model) for contextual workflow dialogue.
Answer the user's question using the supplied study-design context.
Do not claim that workflow state has changed. Provide advice only.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Tool: workflow_context_dialogue
Output contract:
{
"plan": "string <=300 chars",
"answer": "string <=1200 chars",
"current_step_guidance": ["string <=200 chars"],
"cautions": ["string <=200 chars"],
"suggested_next_actions": ["string <=200 chars"]
}

### HEURISTICS/RULES
- Answer the user's question in the context of the provided study intent and current workflow step.
- Keep the answer advisory only; do not imply that any workflow choice or artifact has already changed.
- Use the current role and current_context only when they help answer the question.
- Prefer concrete guidance tied to the user's present step over general OHDSI background.
- If context is sparse, answer conservatively and mention what additional detail would sharpen the guidance.
- Use sparse bullets in current_step_guidance, cautions, and suggested_next_actions.

Constraints:
- JSON only; no markdown/fences.
- Keep output < 10 KB.
Loading
Loading