Coverage for astrocyte/pipeline/question_annotator.py: 0%
59 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""PR2 D.5: LLM-backed question annotator.
3Replaces the regex-based ``_extract_question_entities`` heuristic with
4a single LLM call that returns both:
61. **Entities** (proper + common nouns the question hinges on) — fed to
7 the entity strategy. Catches lowercase nouns the regex misses
8 ("pendant", "obesity", "dog treats", "taekwondo", "France"), which
9 the open-domain failure analysis identified as the pivot for
10 specific-fact questions.
122. **Date range** (start + end, anchored against a ``reference_date``) —
13 fed to the temporal strategy as a *narrow* window. PR2-D.1-4 LME
14 temporal-reasoning was 0% because we passed the temporal strategy
15 the full conversation date range; without question-side date parsing
16 it had nothing to filter on. This module gives it a real window.
18One LLM call per question (~$0.0002 at gpt-4o-mini prices). Both
19fields are optional — the orchestrator handles missing entities or
20date_range gracefully.
22See:
23- docs/_design/recall.md §6 (recall pipeline, mode classifier slot)
24- PR2-D.1-4 LME gate analysis (temporal-reasoning 0% root cause)
25"""
27from __future__ import annotations
29import json
30import logging
31from dataclasses import dataclass
32from datetime import datetime, timezone
33from typing import TYPE_CHECKING
35if TYPE_CHECKING:
36 from astrocyte.provider import LLMProvider
38from astrocyte.pipeline._json_tolerant import tolerant_json_loads_or_raise
39from astrocyte.types import Message
41logger = logging.getLogger("astrocyte.pipeline.question_annotator")
44@dataclass
45class QuestionAnnotation:
46 """One question's parsed structure for the section recall driver.
48 Both fields are optional. ``entities=[]`` skips the entity
49 strategy; ``date_range=None`` skips the temporal strategy."""
51 entities: list[str]
52 date_range: tuple[datetime, datetime] | None
55_PROMPT = """You are an analyst extracting search keys from a user's question about a long conversation transcript.
57Reference date (treat as "today" when resolving relative phrases): {reference_date}
59Return ONLY a JSON object with these keys:
61- "entities": array of strings — names, places, things, concepts, activities the question hinges on. Include BOTH proper nouns (people, places, brands) AND concrete common nouns (objects, activities, conditions). Skip stopwords (the, a, did, what, when, etc.) and tense markers. Aim for 1-6 entries.
63- "date_range": object with ISO-8601 "start" and "end" date strings, OR {{"start": null, "end": null}} when the question has no temporal anchor. Use the reference date to resolve relative phrases like "last week" or "two months ago".
65Examples (reference_date "22 October, 2023"):
67Q: "What did Caroline research?"
68→ {{"entities": ["Caroline", "research"], "date_range": {{"start": null, "end": null}}}}
70Q: "In what country did Jolene's mother buy her the pendant?"
71→ {{"entities": ["Jolene", "mother", "pendant", "country"], "date_range": {{"start": null, "end": null}}}}
73Q: "What are John's suspected health problems?"
74→ {{"entities": ["John", "health problems"], "date_range": {{"start": null, "end": null}}}}
76Q: "What did Caroline say in May 2023?"
77→ {{"entities": ["Caroline"], "date_range": {{"start": "2023-05-01", "end": "2023-05-31"}}}}
79Q: "Who did Maria have dinner with on May 3, 2023?"
80→ {{"entities": ["Maria", "dinner"], "date_range": {{"start": "2023-05-03", "end": "2023-05-03"}}}}
82Q: "What was Caroline doing two months ago?"
83→ {{"entities": ["Caroline"], "date_range": {{"start": "2023-08-01", "end": "2023-08-31"}}}}
85Q: "What temporary job did Jon take to cover expenses?"
86→ {{"entities": ["Jon", "temporary job", "expenses"], "date_range": {{"start": null, "end": null}}}}
88Question: {question}
89Output (JSON only):"""
92async def annotate_question(
93 provider: "LLMProvider",
94 question: str,
95 *,
96 reference_date: str | None = None,
97 model: str | None = None,
98) -> QuestionAnnotation:
99 """Single LLM call that returns entities + date_range.
101 ``reference_date`` is the human-readable date string from the
102 conv_tree (e.g. "22 October, 2023"). When None, the prompt uses a
103 placeholder; date phrases referencing "today" can't resolve, but
104 explicit dates still parse.
106 Returns ``QuestionAnnotation(entities=[], date_range=None)`` on
107 LLM failure or parse error — the orchestrator degrades gracefully
108 (just skips the entity / temporal strategies for this question).
109 """
110 if not question.strip():
111 return QuestionAnnotation(entities=[], date_range=None)
113 prompt = _PROMPT.format(
114 question=question,
115 reference_date=reference_date or "(unknown)",
116 )
118 try:
119 completion = await provider.complete(
120 messages=[Message(role="user", content=prompt)],
121 model=model,
122 max_tokens=200,
123 temperature=0.0,
124 response_format={"type": "json_object"},
125 )
126 except Exception as exc: # noqa: BLE001 — annotator failure shouldn't tank a question
127 logger.warning(
128 "annotate_question: LLM call failed for q=%r: %s: %s",
129 question[:80],
130 type(exc).__name__,
131 exc,
132 )
133 return QuestionAnnotation(entities=[], date_range=None)
135 try:
136 parsed = tolerant_json_loads_or_raise(completion.text)
137 except json.JSONDecodeError:
138 logger.warning(
139 "annotate_question: JSON parse failed for q=%r; raw=%r",
140 question[:80],
141 completion.text[:120],
142 )
143 return QuestionAnnotation(entities=[], date_range=None)
145 raw_entities = parsed.get("entities") or []
146 entities: list[str] = []
147 seen: set[str] = set()
148 for e in raw_entities:
149 if not isinstance(e, str):
150 continue
151 clean = e.strip()
152 if not clean:
153 continue
154 key = clean.casefold()
155 if key in seen:
156 continue
157 seen.add(key)
158 entities.append(clean)
160 date_range = _parse_iso_range(parsed.get("date_range"))
161 return QuestionAnnotation(entities=entities, date_range=date_range)
164def _parse_iso_range(raw) -> tuple[datetime, datetime] | None:
165 """Coerce ``{"start": "YYYY-MM-DD", "end": "YYYY-MM-DD"}`` → tz-aware
166 UTC datetime tuple. Returns ``None`` on missing / malformed input."""
167 if not isinstance(raw, dict):
168 return None
169 start_s = raw.get("start")
170 end_s = raw.get("end")
171 if not start_s or not end_s:
172 return None
173 try:
174 start = datetime.strptime(start_s, "%Y-%m-%d").replace(tzinfo=timezone.utc)
175 end = datetime.strptime(end_s, "%Y-%m-%d").replace(
176 hour=23,
177 minute=59,
178 second=59,
179 tzinfo=timezone.utc,
180 )
181 except (ValueError, TypeError):
182 return None
183 if end < start:
184 # LLM occasionally swaps; tolerate.
185 start, end = end, start
186 return (start, end)