Coverage for astrocyte/pipeline/query_plan.py: 97%
63 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
1"""Lightweight query planning for memory synthesis.
3This module deliberately stays deterministic and cheap. It identifies the
4question shapes that need broader evidence assembly than a single fact lookup:
5aggregate/list questions, temporal questions, inference/open-domain questions,
6and adversarial/unanswerable-looking prompts.
7"""
9from __future__ import annotations
11import re
12from dataclasses import dataclass
14from astrocyte.pipeline.query_intent import QueryIntent, classify_query_intent
15from astrocyte.pipeline.temporal import temporal_guidance_for_query
18@dataclass(frozen=True)
19class QueryPlan:
20 """Derived plan for recall and reflect behavior."""
22 intent: QueryIntent
23 needs_temporal_reasoning: bool = False
24 needs_multi_hop_synthesis: bool = False
25 needs_aggregate_answer: bool = False
26 needs_inference: bool = False
27 may_be_adversarial: bool = False
28 prompt_variant: str | None = None
29 recall_max_results: int = 30
30 reflect_rank_limit: int = 12
31 reflect_expand_limit: int = 18
32 guidance: str | None = None
35_AGGREGATE_RE = re.compile(
36 r"\b("
37 r"what\s+(activities|events|books|items|types|ways|symbols|instruments|artists|bands)|"
38 r"how\s+many\s+times|"
39 r"in\s+what\s+ways|"
40 r"what\s+has\s+\w+\s+(painted|bought|read|done)"
41 r")\b",
42 re.IGNORECASE,
43)
44_INFERENCE_RE = re.compile(
45 r"\b(would|likely|probably|considered|interested\s+in|prefer|leaning|pursue)\b",
46 re.IGNORECASE,
47)
48_ADVERSARIAL_RE = re.compile(
49 r"\b(type\s+of|what\s+type|did\s+\w+\s+make|who\s+is\s+\w+\s+a\s+fan\s+of|what\s+happened\s+to)\b",
50 re.IGNORECASE,
51)
52_MULTI_HOP_RE = re.compile(
53 r"\b(across|combine|relationship|support|participat(?:e|ed|ing)|activities|events|ways|types|how\s+many)\b",
54 re.IGNORECASE,
55)
58def build_query_plan(query: str) -> QueryPlan:
59 """Classify query shape and choose retrieval/synthesis guidance."""
61 query_text = query or ""
62 intent = classify_query_intent(query_text).intent
63 temporal_guidance = temporal_guidance_for_query(query_text)
64 needs_temporal = intent == QueryIntent.TEMPORAL or temporal_guidance is not None
65 needs_aggregate = bool(_AGGREGATE_RE.search(query_text))
66 needs_inference = bool(_INFERENCE_RE.search(query_text))
67 may_be_adversarial = bool(_ADVERSARIAL_RE.search(query_text))
68 needs_multi_hop = needs_aggregate or bool(_MULTI_HOP_RE.search(query_text))
70 prompt_variant: str | None = None
71 if needs_temporal:
72 prompt_variant = "temporal_aware"
73 elif needs_aggregate or needs_multi_hop:
74 prompt_variant = "grounded_synthesis"
75 elif needs_inference:
76 prompt_variant = "evidence_inference"
77 elif may_be_adversarial:
78 prompt_variant = "evidence_strict"
80 recall_max_results = 30
81 reflect_rank_limit = 12
82 reflect_expand_limit = 18
83 if needs_aggregate or needs_multi_hop:
84 recall_max_results = 40
85 reflect_rank_limit = 18
86 reflect_expand_limit = 26
87 if needs_inference:
88 recall_max_results = max(recall_max_results, 36)
89 reflect_rank_limit = max(reflect_rank_limit, 16)
90 reflect_expand_limit = max(reflect_expand_limit, 24)
92 guidance_parts: list[str] = []
93 if needs_aggregate:
94 guidance_parts.append(
95 "Aggregate/list question: scan all provided memories for distinct matching facts; "
96 "do not stop after the first plausible memory."
97 )
98 if needs_multi_hop:
99 guidance_parts.append(
100 "Multi-hop question: combine directly related facts across memories when they share "
101 "the same person, event, object, or timeframe."
102 )
103 if needs_inference:
104 guidance_parts.append(
105 "Inference question: answer with calibrated language only when the memories directly support it."
106 )
107 if may_be_adversarial:
108 guidance_parts.append(
109 "Adversarial check: verify the named person and premise match the memories before answering."
110 )
111 if temporal_guidance:
112 guidance_parts.append(temporal_guidance)
114 return QueryPlan(
115 intent=intent,
116 needs_temporal_reasoning=needs_temporal,
117 needs_multi_hop_synthesis=needs_multi_hop,
118 needs_aggregate_answer=needs_aggregate,
119 needs_inference=needs_inference,
120 may_be_adversarial=may_be_adversarial,
121 prompt_variant=prompt_variant,
122 recall_max_results=recall_max_results,
123 reflect_rank_limit=reflect_rank_limit,
124 reflect_expand_limit=reflect_expand_limit,
125 guidance="\n".join(guidance_parts) if guidance_parts else None,
126 )