chipgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,266 @@
1
+ """The API-runtime answerer behind `chipgraph ask`: retrieve, ask a model, check, retry.
2
+
3
+ `answer_question` runs the same contract as the Claude Code path (`/chipgraph:ask`): the
4
+ model sees only the sources `ask_context` returned, must reply with an `AskAnswer` JSON
5
+ object, and the reply goes through `ask_check`. A rejected reply is sent back once with the
6
+ reasons; an answer that still fails is never shown: the result is "I don't know" with the
7
+ reasons. With no source at all the model is not called.
8
+
9
+ The provider comes from the profile's `models.providers` (`make_provider`), wrapped in a
10
+ `GuardedProvider` (the `nda` rule, retry, accounting); the model id is `models.tiers.small`.
11
+ With no provider configured, `run_ask` returns the retrieved sources only (`no_provider`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ from typing import Literal
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+ from chipgraph.adapters.llm import AnthropicProvider, FakeProvider, GuardedProvider, LlmError
23
+ from chipgraph.app.context import AppContext
24
+ from chipgraph.app.errors import AppError
25
+ from chipgraph.core.config.models import Profile
26
+ from chipgraph.core.contracts.types import DataLabel
27
+ from chipgraph.core.plugin_api.protocols import LlmProvider
28
+ from chipgraph.core.plugin_api.types import LlmMessage, LlmRequest
29
+ from chipgraph.packs.assist.ask._project import AskProject
30
+ from chipgraph.packs.assist.ask.check import check_answer
31
+ from chipgraph.packs.assist.ask.contract import (
32
+ UNKNOWN_ANSWER,
33
+ AskAnswer,
34
+ AskCheck,
35
+ AskContext,
36
+ )
37
+ from chipgraph.packs.assist.ask.retrieve import DEFAULT_LIMIT, retrieve
38
+
39
+ MAX_ATTEMPTS = 2
40
+ """The first reply, plus one retry with the checker's reasons."""
41
+
42
+ MAX_TOKENS = 1024
43
+
44
+ AskStatus = Literal["answered", "unknown", "rejected", "no_provider"]
45
+
46
+ SYSTEM_PROMPT = """\
47
+ You answer one question about a chip design project, using ONLY the sources you are given
48
+ (entities of its Design Model and lines of its documents). Rules:
49
+
50
+ - Every fact in your answer must come from a source. Cite the sources you used, each by
51
+ its exact `citation` string ('model:<key>' or 'path:line'), or by its `defined_at`.
52
+ - Never use outside knowledge and never guess a number, a name or a behaviour.
53
+ - If the sources do not answer the question, set "unknown" to true, say briefly what is
54
+ missing in "answer", and cite nothing.
55
+
56
+ Reply with exactly one JSON object and nothing else:
57
+ {"answer": "<short answer>", "citations": ["<citation>", ...], "unknown": false}
58
+ """
59
+
60
+
61
+ class AskResult(BaseModel):
62
+ """What `chipgraph ask` prints: the checked answer (or none), and the sources."""
63
+
64
+ model_config = ConfigDict(extra="forbid", frozen=True)
65
+
66
+ schema_version: int = 1
67
+ status: AskStatus = Field(
68
+ description=(
69
+ "'answered' or 'unknown' (a verified answer), 'rejected' (no reply passed the "
70
+ "check: the answer is 'I don't know'), 'no_provider' (sources only)."
71
+ )
72
+ )
73
+ question: str
74
+ answer: AskAnswer | None = Field(default=None, description="The verified answer.")
75
+ check: AskCheck | None = Field(default=None, description="The last check that ran.")
76
+ attempts: int = Field(default=0, description="Model calls made.")
77
+ context: AskContext = Field(description="The retrieved sources.")
78
+ provider: str | None = Field(default=None, description="The provider used, if any.")
79
+ model: str | None = Field(default=None, description="The model id used, if any.")
80
+
81
+
82
+ def make_provider(profile: Profile, name: str | None = None) -> LlmProvider | None:
83
+ """The `LlmProvider` configured in `models.providers`, or None when there is none.
84
+
85
+ `name` picks one provider; it is required when several are configured. `anthropic`
86
+ is the Anthropic API, `fake` the scripted test provider, and any other name an
87
+ Anthropic-compatible endpoint (`base_url` in its config).
88
+ """
89
+ providers = profile.models.providers
90
+ if name is None:
91
+ if not providers:
92
+ return None
93
+ if len(providers) > 1:
94
+ raise AppError(
95
+ f"several providers in models.providers ({', '.join(sorted(providers))}); "
96
+ "pick one with --provider"
97
+ )
98
+ [name] = providers
99
+ elif name not in providers:
100
+ known = ", ".join(sorted(providers)) or "none"
101
+ raise AppError(f"no provider {name!r} in models.providers (configured: {known})")
102
+ if name == "fake":
103
+ return FakeProvider(name="fake")
104
+ try:
105
+ return AnthropicProvider.from_models(profile.models, name)
106
+ except LlmError as exc:
107
+ raise AppError(str(exc)) from exc
108
+
109
+
110
+ def run_ask(
111
+ ctx: AppContext,
112
+ question: str,
113
+ *,
114
+ limit: int = DEFAULT_LIMIT,
115
+ provider_name: str | None = None,
116
+ ) -> AskResult:
117
+ """`chipgraph ask`: answer with the profile's provider, or return the sources only."""
118
+ profile = ctx.require_profile().profile
119
+ project = AskProject.load(ctx)
120
+ provider = make_provider(profile, provider_name)
121
+ if provider is None:
122
+ return AskResult(
123
+ status="no_provider", question=question, context=retrieve(project, question, limit)
124
+ )
125
+ model = profile.models.tiers.get("small")
126
+ if not model:
127
+ raise AppError(
128
+ f"provider {provider.name!r} is configured but models.tiers.small is not: set the "
129
+ "model id /ask should use"
130
+ )
131
+ guarded = GuardedProvider(provider, data=profile.data)
132
+ return asyncio.run(answer_question(project, question, guarded, model=model, limit=limit))
133
+
134
+
135
+ async def answer_question(
136
+ project: AskProject,
137
+ question: str,
138
+ provider: LlmProvider,
139
+ *,
140
+ model: str,
141
+ limit: int = DEFAULT_LIMIT,
142
+ ) -> AskResult:
143
+ """Retrieve, ask `provider`, check the reply, retry once with the reasons."""
144
+ context = retrieve(project, question, limit)
145
+
146
+ def result(
147
+ status: AskStatus, answer: AskAnswer, check: AskCheck | None, attempts: int
148
+ ) -> AskResult:
149
+ return AskResult(
150
+ status=status,
151
+ question=question,
152
+ answer=answer,
153
+ check=check,
154
+ attempts=attempts,
155
+ context=context,
156
+ provider=provider.name,
157
+ model=model,
158
+ )
159
+
160
+ if context.no_sources:
161
+ return result("unknown", AskAnswer(answer=UNKNOWN_ANSWER, unknown=True), None, 0)
162
+
163
+ messages = [
164
+ LlmMessage(role="system", content=SYSTEM_PROMPT),
165
+ LlmMessage(role="user", content=_user_prompt(context)),
166
+ ]
167
+ labels = _labels(project, context)
168
+ check: AskCheck | None = None
169
+ for attempt in range(1, MAX_ATTEMPTS + 1):
170
+ request = LlmRequest(
171
+ model=model, messages=tuple(messages), max_tokens=MAX_TOKENS, labels=labels
172
+ )
173
+ try:
174
+ response = await provider.complete(request)
175
+ except LlmError as exc:
176
+ raise AppError(f"/ask: the model call failed: {exc}") from exc
177
+ parsed, parse_error = parse_answer(response.text)
178
+ if parsed is None:
179
+ check = AskCheck(ok=False, reasons=(parse_error or "not an answer object",))
180
+ else:
181
+ check = check_answer(project, parsed)
182
+ if check.ok and check.answer is not None:
183
+ status: AskStatus = "unknown" if check.answer.unknown else "answered"
184
+ return result(status, check.answer, check, attempt)
185
+ messages.append(LlmMessage(role="assistant", content=response.text))
186
+ messages.append(LlmMessage(role="user", content=_retry_prompt(check)))
187
+
188
+ fallback = AskAnswer(
189
+ answer="I don't know: no answer could be verified against this project's sources.",
190
+ unknown=True,
191
+ )
192
+ return result("rejected", fallback, check, MAX_ATTEMPTS)
193
+
194
+
195
+ def parse_answer(text: str) -> tuple[AskAnswer | None, str | None]:
196
+ """An `AskAnswer` from a model reply (a JSON object, maybe fenced), or the error."""
197
+ raw = text.strip()
198
+ if raw.startswith("```"):
199
+ raw = raw.split("\n", 1)[1] if "\n" in raw else ""
200
+ raw = raw.rsplit("```", 1)[0]
201
+ start, end = raw.find("{"), raw.rfind("}")
202
+ if start < 0 or end < start:
203
+ return None, "the reply is not a JSON object"
204
+ try:
205
+ data = json.loads(raw[start : end + 1])
206
+ except json.JSONDecodeError as exc:
207
+ return None, f"the reply is not valid JSON: {exc.msg}"
208
+ if not isinstance(data, dict):
209
+ return None, "the reply is not a JSON object"
210
+ answer = data.get("answer", "")
211
+ citations = data.get("citations", [])
212
+ unknown = data.get("unknown", False)
213
+ if not isinstance(answer, str):
214
+ return None, "'answer' must be a string"
215
+ if not isinstance(citations, list) or not all(isinstance(c, str) for c in citations):
216
+ return None, "'citations' must be a list of strings"
217
+ if not isinstance(unknown, bool):
218
+ return None, "'unknown' must be true or false"
219
+ return AskAnswer(answer=answer, citations=tuple(citations), unknown=unknown), None
220
+
221
+
222
+ def _user_prompt(context: AskContext) -> str:
223
+ sources = [s.model_dump(mode="json", exclude_defaults=True) for s in context.sources]
224
+ return (
225
+ f"Question: {context.question}\n\n"
226
+ f"Sources ({len(sources)}), best first:\n"
227
+ f"{json.dumps(sources, indent=1)}\n\n"
228
+ f"{context.note}"
229
+ )
230
+
231
+
232
+ def _retry_prompt(check: AskCheck) -> str:
233
+ reasons = "\n".join(f"- {r}" for r in check.reasons)
234
+ return (
235
+ "Your answer was rejected by the citation check:\n"
236
+ f"{reasons}\n"
237
+ "Reply again with one JSON object. Cite only `citation` or `defined_at` strings from "
238
+ "the sources above; if they do not answer the question, set unknown to true."
239
+ )
240
+
241
+
242
+ def _labels(project: AskProject, context: AskContext) -> tuple[DataLabel, ...]:
243
+ """The data labels of every file behind the sources (never 'nda': those are excluded)."""
244
+ files: set[str] = set()
245
+ for source in context.sources:
246
+ for citation in (source.citation, source.defined_at):
247
+ if citation and not citation.startswith("model:"):
248
+ files.add(citation.rsplit(":", 1)[0])
249
+ if source.kind == "model":
250
+ entity = project.model.get(source.citation.removeprefix("model:"))
251
+ if entity is not None and entity.source.file:
252
+ files.add(entity.source.file)
253
+ labels = {project.labels.label_for(f) for f in files} or {project.labels.default}
254
+ return tuple(sorted(labels))
255
+
256
+
257
+ __all__ = [
258
+ "MAX_ATTEMPTS",
259
+ "SYSTEM_PROMPT",
260
+ "AskResult",
261
+ "AskStatus",
262
+ "answer_question",
263
+ "make_provider",
264
+ "parse_answer",
265
+ "run_ask",
266
+ ]
@@ -0,0 +1,182 @@
1
+ """`ask_check`: verify an `/ask` answer's citations before anyone sees the answer.
2
+
3
+ Rules (DESIGN 4.8: no evidence, no claim):
4
+
5
+ - A citation is either `path:line` / `path:start-end` (the path must be an indexed
6
+ document and every line must exist; a range spans at most `MAX_RANGE` lines) or
7
+ `model:<key>` (the key must exist in the Design Model). A bare model key is accepted and
8
+ written back as `model:<key>`. Nothing from an `nda` file is citable.
9
+ - Every citation must be valid: one invented citation rejects the answer.
10
+ - An answer with `unknown: false` needs at least one valid citation and some text.
11
+ - An answer with `unknown: true` needs no citation (and any it gives must be valid).
12
+
13
+ The result is the verified answer (citations in canonical form, duplicates dropped) or the
14
+ reasons it was rejected, with a verdict per citation so the answerer can fix it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+ import re
21
+
22
+ from chipgraph.app.context import AppContext
23
+ from chipgraph.packs.assist.ask._project import AskProject
24
+ from chipgraph.packs.assist.ask.contract import (
25
+ UNKNOWN_ANSWER,
26
+ AskAnswer,
27
+ AskCheck,
28
+ CitationCheck,
29
+ )
30
+
31
+ MAX_RANGE = 30
32
+ """A `path:start-end` citation may span at most this many lines."""
33
+
34
+ MAX_CITATIONS = 20
35
+ """An answer may carry at most this many citations."""
36
+
37
+ _FILE_LINE = re.compile(r"^(?P<path>.+?):(?P<start>\d+)(?:-(?P<end>\d+))?$")
38
+ _MAX_TEXT = 400
39
+
40
+
41
+ def ask_check(ctx: AppContext, answer: AskAnswer) -> AskCheck:
42
+ """Verify `answer` against the project of `ctx` (see the module docstring)."""
43
+ return check_answer(AskProject.load(ctx), answer)
44
+
45
+
46
+ def check_answer(project: AskProject, answer: AskAnswer) -> AskCheck:
47
+ """`ask_check` on an already loaded `AskProject`."""
48
+ reasons: list[str] = []
49
+ checks = [check_citation(project, c) for c in answer.citations[:MAX_CITATIONS]]
50
+ if len(answer.citations) > MAX_CITATIONS:
51
+ reasons.append(
52
+ f"too many citations ({len(answer.citations)}); cite at most {MAX_CITATIONS}"
53
+ )
54
+ for check in checks:
55
+ if not check.valid:
56
+ reasons.append(f"invalid citation {check.citation!r}: {check.reason}")
57
+ valid = [c for c in checks if c.valid]
58
+ text = answer.answer.strip()
59
+ if not answer.unknown:
60
+ if not text:
61
+ reasons.append("the answer text is empty")
62
+ if not valid:
63
+ reasons.append(
64
+ "an answer needs at least one valid citation ('path:line' or 'model:<key>' "
65
+ "from ask_context); if the sources do not answer the question, set unknown: true"
66
+ )
67
+ if reasons:
68
+ return AskCheck(ok=False, citations=tuple(checks), reasons=tuple(reasons))
69
+
70
+ canonical: list[str] = []
71
+ for check in valid:
72
+ if check.normalized is not None and check.normalized not in canonical:
73
+ canonical.append(check.normalized)
74
+ verified = AskAnswer(
75
+ answer=text or UNKNOWN_ANSWER,
76
+ citations=tuple(canonical),
77
+ unknown=answer.unknown,
78
+ )
79
+ return AskCheck(ok=True, answer=verified, citations=tuple(checks))
80
+
81
+
82
+ def check_citation(project: AskProject, citation: str) -> CitationCheck:
83
+ """The verdict on one citation string."""
84
+ raw = citation
85
+ text = citation.strip()
86
+ if not text:
87
+ return CitationCheck(citation=raw, valid=False, reason="empty citation")
88
+ if text.startswith("model:"):
89
+ return _check_key(project, raw, text.removeprefix("model:").strip())
90
+ match = _FILE_LINE.match(text)
91
+ if match is not None:
92
+ path = _normalise_path(match.group("path"))
93
+ if project.docs.is_indexed(path) or project.model.get(text) is None:
94
+ return _check_lines(project, raw, path, match.group("start"), match.group("end"))
95
+ if project.model.get(text) is not None:
96
+ return _check_key(project, raw, text)
97
+ return CitationCheck(
98
+ citation=raw,
99
+ valid=False,
100
+ reason=(
101
+ "not a 'path:line' or 'model:<key>' citation"
102
+ + _did_you_mean(text, list(project.model.entities))
103
+ ),
104
+ )
105
+
106
+
107
+ def _check_key(project: AskProject, raw: str, key: str) -> CitationCheck:
108
+ entity = project.model.get(key)
109
+ if entity is None:
110
+ return CitationCheck(
111
+ citation=raw,
112
+ valid=False,
113
+ kind="model",
114
+ reason=f"no model key {key!r}" + _did_you_mean(key, list(project.model.entities)),
115
+ )
116
+ if not project.visible(entity):
117
+ return CitationCheck(
118
+ citation=raw,
119
+ valid=False,
120
+ kind="model",
121
+ reason=f"{key!r} comes from an 'nda' file, which /ask may not cite",
122
+ )
123
+ return CitationCheck(
124
+ citation=raw,
125
+ valid=True,
126
+ kind="model",
127
+ normalized=f"model:{key}",
128
+ text=_clip(project.summary(entity)),
129
+ )
130
+
131
+
132
+ def _check_lines(
133
+ project: AskProject, raw: str, path: str, start_text: str, end_text: str | None
134
+ ) -> CitationCheck:
135
+ counts = project.docs.line_counts()
136
+ if path not in counts:
137
+ label = project.labels.label_for(path)
138
+ why = (
139
+ f"{path} is labelled 'nda' and is never indexed for /ask"
140
+ if label == "nda"
141
+ else f"{path} is not an indexed document" + _did_you_mean(path, list(counts))
142
+ )
143
+ return CitationCheck(citation=raw, valid=False, kind="document", reason=why)
144
+ start = int(start_text)
145
+ end = int(end_text) if end_text is not None else start
146
+ total = counts[path]
147
+ if start < 1 or end < start:
148
+ reason = f"bad line range {start}-{end}"
149
+ elif end > total:
150
+ reason = f"{path} has {total} lines; line {end} does not exist"
151
+ elif end - start + 1 > MAX_RANGE:
152
+ reason = f"a range may span at most {MAX_RANGE} lines; cite the exact lines"
153
+ else:
154
+ rows = project.docs.lines(path, start, end)
155
+ normalized = f"{path}:{start}" if start == end else f"{path}:{start}-{end}"
156
+ return CitationCheck(
157
+ citation=raw,
158
+ valid=True,
159
+ kind="document",
160
+ normalized=normalized,
161
+ text=_clip("\n".join(t for _, t in rows)),
162
+ )
163
+ return CitationCheck(citation=raw, valid=False, kind="document", reason=reason)
164
+
165
+
166
+ def _normalise_path(path: str) -> str:
167
+ text = path.strip().replace("\\", "/")
168
+ while text.startswith("./"):
169
+ text = text[2:]
170
+ return text
171
+
172
+
173
+ def _did_you_mean(name: str, choices: list[str]) -> str:
174
+ matches = difflib.get_close_matches(name, choices, n=3, cutoff=0.6)
175
+ return f"; did you mean: {', '.join(matches)}" if matches else ""
176
+
177
+
178
+ def _clip(text: str) -> str:
179
+ return text if len(text) <= _MAX_TEXT else text[: _MAX_TEXT - 3] + "..."
180
+
181
+
182
+ __all__ = ["MAX_CITATIONS", "MAX_RANGE", "ask_check", "check_answer", "check_citation"]
@@ -0,0 +1,115 @@
1
+ """The `/ask` data contracts: retrieved sources, the answer, and the citation check.
2
+
3
+ An answer is `{"answer": "...", "citations": ["file:line" | "model:<key>", ...],
4
+ "unknown": bool}` (`AskAnswer`). `ask_check` verifies it and returns an `AskCheck`: the
5
+ verified answer, or the reasons it was rejected. Every model is JSON-dumpable, frozen, and
6
+ carries a `schema_version`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ UNKNOWN_ANSWER = "I don't know: no source in this project's Design Model or documents answers it."
16
+ """The answer text used when there is no source, or no answer could be verified."""
17
+
18
+
19
+ class DocLine(BaseModel):
20
+ """One line of an indexed document."""
21
+
22
+ model_config = ConfigDict(extra="forbid", frozen=True)
23
+
24
+ line: int = Field(ge=1, description="1-based line number.")
25
+ text: str = Field(description="The line's text.")
26
+
27
+
28
+ class AskSource(BaseModel):
29
+ """One source `ask_context` returned: a model entity or a document line."""
30
+
31
+ model_config = ConfigDict(extra="forbid", frozen=True)
32
+
33
+ citation: str = Field(description="What to cite: 'model:<key>' or 'path:line'.")
34
+ kind: Literal["model", "document"] = Field(description="A model entity or a document line.")
35
+ text: str = Field(description="The entity's facts, or the document line.")
36
+ origin: Literal["lookup", "search"] = Field(
37
+ description="'lookup': a typed model query on a name in the question; 'search': FTS5."
38
+ )
39
+ defined_at: str | None = Field(
40
+ default=None,
41
+ description="For a model entity: the indexed 'path:line' it was read from, also citable.",
42
+ )
43
+ context: tuple[DocLine, ...] = Field(
44
+ default=(), description="For a document line: the lines around it."
45
+ )
46
+
47
+
48
+ class AskContext(BaseModel):
49
+ """The result of `ask_context`: the sources an answer may use, and only those."""
50
+
51
+ model_config = ConfigDict(extra="forbid", frozen=True)
52
+
53
+ schema_version: int = 1
54
+ question: str = Field(description="The question, as asked.")
55
+ query: str | None = Field(description="The FTS5 query used, or None when no term was left.")
56
+ sources: tuple[AskSource, ...] = Field(default=(), description="Best first.")
57
+ no_sources: bool = Field(description="True when nothing matched: the answer is 'unknown'.")
58
+ note: str = Field(description="How to use the sources.")
59
+
60
+
61
+ class AskAnswer(BaseModel):
62
+ """An answer to one `/ask` question: text, citations, and whether it is 'unknown'."""
63
+
64
+ model_config = ConfigDict(extra="forbid", frozen=True)
65
+
66
+ schema_version: int = 1
67
+ answer: str = Field(description="The answer text.")
68
+ citations: tuple[str, ...] = Field(
69
+ default=(), description="'path:line' (or 'path:start-end') or 'model:<key>'."
70
+ )
71
+ unknown: bool = Field(
72
+ default=False, description="True when the sources do not answer the question."
73
+ )
74
+
75
+
76
+ class CitationCheck(BaseModel):
77
+ """The verdict on one citation."""
78
+
79
+ model_config = ConfigDict(extra="forbid", frozen=True)
80
+
81
+ citation: str = Field(description="The citation as given.")
82
+ valid: bool = Field(description="True when it names an indexed line or a model key.")
83
+ kind: Literal["model", "document"] | None = Field(
84
+ default=None, description="What it names, when that could be told."
85
+ )
86
+ normalized: str | None = Field(
87
+ default=None, description="The canonical form: 'path:line[-end]' or 'model:<key>'."
88
+ )
89
+ text: str | None = Field(default=None, description="What it points at, when valid.")
90
+ reason: str | None = Field(default=None, description="Why it is invalid.")
91
+
92
+
93
+ class AskCheck(BaseModel):
94
+ """The result of `ask_check`: the verified answer (when `ok`) or the reasons."""
95
+
96
+ model_config = ConfigDict(extra="forbid", frozen=True)
97
+
98
+ schema_version: int = 1
99
+ ok: bool = Field(description="True when the answer is accepted.")
100
+ answer: AskAnswer | None = Field(
101
+ default=None, description="The verified answer (canonical citations), when ok."
102
+ )
103
+ citations: tuple[CitationCheck, ...] = Field(default=(), description="One per citation.")
104
+ reasons: tuple[str, ...] = Field(default=(), description="Why it was rejected.")
105
+
106
+
107
+ __all__ = [
108
+ "UNKNOWN_ANSWER",
109
+ "AskAnswer",
110
+ "AskCheck",
111
+ "AskContext",
112
+ "AskSource",
113
+ "CitationCheck",
114
+ "DocLine",
115
+ ]