devrel-origin 0.2.14__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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kai — Content Creator Agent
|
|
3
|
+
|
|
4
|
+
Produces technical tutorials, blog posts, and changelog announcements
|
|
5
|
+
grounded in the product knowledge base.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from devrel_origin.core.base import get_kb_search, load_agent_prompt
|
|
16
|
+
from devrel_origin.core.llm import LLMClient
|
|
17
|
+
from devrel_origin.quality import generate_with_pipeline
|
|
18
|
+
from devrel_origin.quality.editorial import AbortLoud
|
|
19
|
+
from devrel_origin.tools.api_client import PostHogClient
|
|
20
|
+
from devrel_origin.tools.code_validator import CodeValidator
|
|
21
|
+
from devrel_origin.tools.search_tools import SearchTools
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_FILE_PATH_RE = re.compile(
|
|
26
|
+
r"(?:^|[`\s(\[])((?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+\."
|
|
27
|
+
r"(?:py|ts|tsx|js|jsx|md|sql|toml|yaml|yml|xml|json|txt|log|ambr|go|rs|java|rb|php|swift))",
|
|
28
|
+
re.IGNORECASE | re.MULTILINE,
|
|
29
|
+
)
|
|
30
|
+
_ABSOLUTE_PATH_RE = re.compile(
|
|
31
|
+
r"(?<!\w)(/(?:var|etc|opt|usr|tmp|home|srv|app|data)/[A-Za-z0-9_./-]+)",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
_CONFIG_NAME_RE = re.compile(r"\b[A-Z][A-Z0-9]+(?:_[A-Z0-9]+)+\b")
|
|
35
|
+
_SQL_TABLE_RE = re.compile(
|
|
36
|
+
r"\b(?:FROM|JOIN|UPDATE|INTO|DESCRIBE|DESC|TABLE)\s+`?"
|
|
37
|
+
r"([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)`?",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
_SOURCE_LABEL_RE = re.compile(r"(?im)^\s*(?:[-*]\s*)?(?:\*\*)?Sources?(?:\*\*)?\s*:\s*(.+)$")
|
|
41
|
+
_SOURCE_HEADING_RE = re.compile(r"^\s{0,3}#{1,4}\s+sources?\b", re.IGNORECASE)
|
|
42
|
+
_INTERNAL_MARKER_RE = re.compile(
|
|
43
|
+
r"\s*\((?:evidence|context)\s+truncated[^)]*\)|\b(?:evidence|context)\s+truncated\b[:;,.]?",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
_UNSUPPORTED_PLACEHOLDER_RE = re.compile(r"\bYOUR(?:_[A-Z0-9]+)+\b")
|
|
47
|
+
_SQL_BLOCK_RE = re.compile(r"```sql\s*\n(.*?)```", re.IGNORECASE | re.DOTALL)
|
|
48
|
+
_CODE_BLOCK_RE = re.compile(r"```(\w*)\s*\n(.*?)```", re.IGNORECASE | re.DOTALL)
|
|
49
|
+
_CHECK_SECTION_RE = re.compile(
|
|
50
|
+
r"(?ms)^###\s+Check\s+\d+:\s*(.*?)\n(.*?)(?=^###\s+Check\s+\d+:|^##\s+|\Z)"
|
|
51
|
+
)
|
|
52
|
+
_SQL_IDENTIFIER_RE = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b")
|
|
53
|
+
_INTERNAL_IMPORT_RE = re.compile(
|
|
54
|
+
r"(?m)^\s*(?:from\s+((?:posthog|products)\.[A-Za-z0-9_.]+)\s+import|import\s+((?:posthog|products)\.[A-Za-z0-9_.]+))"
|
|
55
|
+
)
|
|
56
|
+
_SQL_IDENTIFIER_SKIP = {
|
|
57
|
+
"and",
|
|
58
|
+
"as",
|
|
59
|
+
"by",
|
|
60
|
+
"case",
|
|
61
|
+
"desc",
|
|
62
|
+
"distinct",
|
|
63
|
+
"from",
|
|
64
|
+
"group",
|
|
65
|
+
"having",
|
|
66
|
+
"in",
|
|
67
|
+
"interval",
|
|
68
|
+
"join",
|
|
69
|
+
"limit",
|
|
70
|
+
"not",
|
|
71
|
+
"null",
|
|
72
|
+
"on",
|
|
73
|
+
"or",
|
|
74
|
+
"order",
|
|
75
|
+
"select",
|
|
76
|
+
"table",
|
|
77
|
+
"then",
|
|
78
|
+
"where",
|
|
79
|
+
"with",
|
|
80
|
+
"count",
|
|
81
|
+
"sum",
|
|
82
|
+
"avg",
|
|
83
|
+
"min",
|
|
84
|
+
"max",
|
|
85
|
+
"now",
|
|
86
|
+
"clusterallreplicas",
|
|
87
|
+
"siphash64",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ContentPiece:
|
|
93
|
+
"""A generated content artifact."""
|
|
94
|
+
|
|
95
|
+
title: str
|
|
96
|
+
content_type: str # tutorial, blog_post, changelog, social
|
|
97
|
+
body: str
|
|
98
|
+
metadata: dict[str, Any]
|
|
99
|
+
grounding_sources: list[str] # knowledge base files referenced
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Kai:
|
|
103
|
+
"""
|
|
104
|
+
Content Creator agent specializing in developer-facing technical content.
|
|
105
|
+
|
|
106
|
+
Capabilities:
|
|
107
|
+
- Technical tutorials with working code examples
|
|
108
|
+
- Blog posts covering product updates and best practices
|
|
109
|
+
- Changelog announcements for new features
|
|
110
|
+
- Content grounded in the knowledge base (not hallucinated)
|
|
111
|
+
|
|
112
|
+
Tools:
|
|
113
|
+
1. knowledge_base_search — Retrieve relevant docs for content grounding
|
|
114
|
+
2. code_validator — Verify code examples compile and run
|
|
115
|
+
3. seo_analyzer — Check content for search optimization
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
_DEFAULT_SYSTEM_PROMPT = """You are Kai, a technical content creator for OpenClaw,
|
|
119
|
+
an open-source system of 10 specialized AI agents that replaces a full DevRel + Sales
|
|
120
|
+
team for DevTools companies. OpenClaw covers community management, social
|
|
121
|
+
listening, feedback synthesis, growth experimentation, content creation, video
|
|
122
|
+
production, documentation generation, competitive intelligence, sales enablement,
|
|
123
|
+
and campaign marketing — all orchestrated through a hub-and-spoke architecture with
|
|
124
|
+
cross-agent data flow. Your role is to write developer-facing content that is:
|
|
125
|
+
|
|
126
|
+
1. TECHNICALLY ACCURATE — Every code example must work. Every API reference must
|
|
127
|
+
be current. Ground all claims in the knowledge base.
|
|
128
|
+
2. DEVELOPER-FIRST — Write for engineers who value precision over marketing fluff.
|
|
129
|
+
Show, don't tell. Code > prose.
|
|
130
|
+
3. SEO-AWARE — Structure content with clear H2/H3 hierarchy, include relevant
|
|
131
|
+
keywords naturally, and write compelling meta descriptions.
|
|
132
|
+
4. ACTIONABLE — Every piece should leave the reader with something they can
|
|
133
|
+
implement immediately.
|
|
134
|
+
|
|
135
|
+
Content types you produce:
|
|
136
|
+
- Step-by-step tutorials (1500-2500 words, working code, clear prerequisites)
|
|
137
|
+
- Blog posts (800-1200 words, opinionated, data-backed)
|
|
138
|
+
- Changelog announcements (200-400 words, what changed, why it matters, how to use it)
|
|
139
|
+
- Social posts (< 280 chars, hook + value + CTA)
|
|
140
|
+
|
|
141
|
+
Always cite which knowledge base documents you referenced."""
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def SYSTEM_PROMPT(self) -> str:
|
|
145
|
+
return self._system_prompt
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
api_client: PostHogClient,
|
|
150
|
+
knowledge_base_path: Path,
|
|
151
|
+
llm_client: Optional[LLMClient] = None,
|
|
152
|
+
search_tools: Optional[SearchTools] = None,
|
|
153
|
+
):
|
|
154
|
+
self.api_client = api_client
|
|
155
|
+
self.knowledge_base_path = knowledge_base_path
|
|
156
|
+
self.llm_client = llm_client
|
|
157
|
+
self.search_tools = search_tools
|
|
158
|
+
self.code_validator = CodeValidator()
|
|
159
|
+
self._system_prompt = load_agent_prompt(
|
|
160
|
+
"kai", "system_prompt.txt", self._DEFAULT_SYSTEM_PROMPT
|
|
161
|
+
)
|
|
162
|
+
self._kb = get_kb_search(
|
|
163
|
+
knowledge_base_path,
|
|
164
|
+
extra_stop_words=frozenset(
|
|
165
|
+
{
|
|
166
|
+
"write",
|
|
167
|
+
"technical",
|
|
168
|
+
"tutorial",
|
|
169
|
+
"addressing",
|
|
170
|
+
"developer",
|
|
171
|
+
"pain",
|
|
172
|
+
"point",
|
|
173
|
+
}
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def search_knowledge_base(
|
|
178
|
+
self,
|
|
179
|
+
query: str,
|
|
180
|
+
max_results: int = 5,
|
|
181
|
+
content_truncate: int = 3000,
|
|
182
|
+
) -> list[dict[str, str]]:
|
|
183
|
+
"""Search the knowledge base for relevant documents.
|
|
184
|
+
|
|
185
|
+
Delegates to the shared KnowledgeBaseSearch. Kept as a public method
|
|
186
|
+
for backward compatibility with tests and external callers.
|
|
187
|
+
"""
|
|
188
|
+
return self._kb.search(query, limit=max_results, content_truncate=content_truncate)
|
|
189
|
+
|
|
190
|
+
def _extract_upstream_context(self, context: dict[str, Any] | None) -> dict[str, Any]:
|
|
191
|
+
"""Extract structured upstream context from SharedContext for content grounding."""
|
|
192
|
+
extracted: dict[str, Any] = {
|
|
193
|
+
"pain_points": [],
|
|
194
|
+
"real_issues": [],
|
|
195
|
+
"architecture_doc": "",
|
|
196
|
+
"dex_summary": "",
|
|
197
|
+
"source_files": [],
|
|
198
|
+
"api_paths": [],
|
|
199
|
+
"content_brief": {},
|
|
200
|
+
"previous_content_titles": [],
|
|
201
|
+
"recurring_themes": [],
|
|
202
|
+
}
|
|
203
|
+
if not context:
|
|
204
|
+
return extracted
|
|
205
|
+
|
|
206
|
+
def symbols_for(module: dict[str, Any], limit: int = 8) -> list[Any]:
|
|
207
|
+
symbols = module.get("symbols", [])
|
|
208
|
+
if isinstance(symbols, list):
|
|
209
|
+
return symbols[:limit]
|
|
210
|
+
if symbols:
|
|
211
|
+
return [symbols]
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
# Cross-run memory → dedup and trend detection
|
|
215
|
+
if "previous_weeks" in context:
|
|
216
|
+
prev = context["previous_weeks"]
|
|
217
|
+
if isinstance(prev, list):
|
|
218
|
+
for week in prev:
|
|
219
|
+
if isinstance(week, dict):
|
|
220
|
+
extracted["previous_content_titles"].extend(week.get("content_titles", []))
|
|
221
|
+
extracted["recurring_themes"].extend(week.get("top_themes", []))
|
|
222
|
+
|
|
223
|
+
# Iris themes → pain points
|
|
224
|
+
if "iris_themes" in context:
|
|
225
|
+
themes = context["iris_themes"]
|
|
226
|
+
if isinstance(themes, dict):
|
|
227
|
+
for t in themes.get("themes", []):
|
|
228
|
+
if isinstance(t, dict):
|
|
229
|
+
extracted["pain_points"].append(
|
|
230
|
+
{
|
|
231
|
+
"title": t.get("title", ""),
|
|
232
|
+
"description": t.get("description", ""),
|
|
233
|
+
"category": t.get("category", ""),
|
|
234
|
+
"severity": t.get("severity", 0),
|
|
235
|
+
"issues": t.get("representative_issues", []),
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Sage triage → real GitHub issues for examples
|
|
240
|
+
if "sage_triage" in context:
|
|
241
|
+
sage = context["sage_triage"]
|
|
242
|
+
if isinstance(sage, dict):
|
|
243
|
+
for issue in sage.get("issues", [])[:10]:
|
|
244
|
+
if isinstance(issue, dict):
|
|
245
|
+
extracted["real_issues"].append(
|
|
246
|
+
{
|
|
247
|
+
"number": issue.get("number"),
|
|
248
|
+
"title": issue.get("title", ""),
|
|
249
|
+
"category": issue.get("category", ""),
|
|
250
|
+
"product_area": issue.get("product_area", ""),
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Dex docs → architecture and API reference for accuracy
|
|
255
|
+
if "dex_docs" in context:
|
|
256
|
+
dex = context["dex_docs"]
|
|
257
|
+
if isinstance(dex, dict):
|
|
258
|
+
extracted["architecture_doc"] = dex.get("architecture_doc", "")[:4000]
|
|
259
|
+
extracted["dex_summary"] = dex.get("llm_summary", "")[:2000]
|
|
260
|
+
modules = dex.get("modules", [])
|
|
261
|
+
if isinstance(modules, list):
|
|
262
|
+
extracted["source_files"] = [
|
|
263
|
+
{
|
|
264
|
+
"path": m.get("path", ""),
|
|
265
|
+
"language": m.get("language", ""),
|
|
266
|
+
"symbols": symbols_for(m),
|
|
267
|
+
"docstring": (m.get("docstring") or "")[:240],
|
|
268
|
+
}
|
|
269
|
+
for m in modules[:30]
|
|
270
|
+
if isinstance(m, dict) and m.get("path")
|
|
271
|
+
]
|
|
272
|
+
api_reference = dex.get("api_reference", {})
|
|
273
|
+
if isinstance(api_reference, dict):
|
|
274
|
+
extracted["api_paths"] = list(api_reference.keys())[:20]
|
|
275
|
+
|
|
276
|
+
brief = context.get("content_brief")
|
|
277
|
+
if isinstance(brief, dict):
|
|
278
|
+
extracted["content_brief"] = brief
|
|
279
|
+
|
|
280
|
+
return extracted
|
|
281
|
+
|
|
282
|
+
def _evidence_gaps(
|
|
283
|
+
self,
|
|
284
|
+
task: str,
|
|
285
|
+
*,
|
|
286
|
+
grounding_docs: list[dict[str, Any]],
|
|
287
|
+
official_docs: str,
|
|
288
|
+
upstream: dict[str, Any],
|
|
289
|
+
) -> list[str]:
|
|
290
|
+
"""Return blocking gaps that would make generated content ungrounded."""
|
|
291
|
+
brief = upstream.get("content_brief") or {}
|
|
292
|
+
grounding_text = "\n".join(
|
|
293
|
+
str(doc.get("content", "")) + "\n" + str(doc.get("source", ""))
|
|
294
|
+
for doc in grounding_docs
|
|
295
|
+
)
|
|
296
|
+
has_repo_evidence = bool(
|
|
297
|
+
upstream.get("architecture_doc")
|
|
298
|
+
or upstream.get("dex_summary")
|
|
299
|
+
or upstream.get("source_files")
|
|
300
|
+
or brief.get("source_files")
|
|
301
|
+
)
|
|
302
|
+
has_file_path_evidence = bool(
|
|
303
|
+
upstream.get("source_files")
|
|
304
|
+
or brief.get("source_files")
|
|
305
|
+
or self._contains_file_path(grounding_text)
|
|
306
|
+
)
|
|
307
|
+
has_product_evidence = bool(grounding_docs or official_docs.strip())
|
|
308
|
+
gaps: list[str] = []
|
|
309
|
+
if not has_product_evidence and not has_repo_evidence:
|
|
310
|
+
gaps.append("no knowledge-base, official-docs, or repository evidence")
|
|
311
|
+
|
|
312
|
+
task_lower = task.lower()
|
|
313
|
+
if self._requires_evidence(task_lower, ("pain point", "developer pain")) and not (
|
|
314
|
+
upstream.get("pain_points") or brief.get("pain_point")
|
|
315
|
+
):
|
|
316
|
+
gaps.append("task requires a developer pain point, but none was provided")
|
|
317
|
+
if self._requires_evidence(task_lower, ("github issue", "real issue")) and not (
|
|
318
|
+
upstream.get("real_issues") or brief.get("github_issues")
|
|
319
|
+
):
|
|
320
|
+
gaps.append("task requires real GitHub issues, but none were provided")
|
|
321
|
+
if (
|
|
322
|
+
self._requires_evidence(task_lower, ("file path", "source code"))
|
|
323
|
+
and not has_file_path_evidence
|
|
324
|
+
):
|
|
325
|
+
gaps.append(
|
|
326
|
+
"task requires repository file paths, but no source-file evidence was provided"
|
|
327
|
+
)
|
|
328
|
+
return gaps
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _requires_evidence(task_lower: str, phrases: tuple[str, ...]) -> bool:
|
|
332
|
+
"""Whether task wording positively requires a class of evidence.
|
|
333
|
+
|
|
334
|
+
Negative or conditional wording such as "avoid GitHub issues unless
|
|
335
|
+
available" should not force an evidence-gate failure. The generation
|
|
336
|
+
prompt already tells Kai not to invent missing evidence.
|
|
337
|
+
"""
|
|
338
|
+
negation_markers = (
|
|
339
|
+
"avoid",
|
|
340
|
+
"without",
|
|
341
|
+
"do not",
|
|
342
|
+
"don't",
|
|
343
|
+
"unless",
|
|
344
|
+
"only if",
|
|
345
|
+
"if available",
|
|
346
|
+
"when available",
|
|
347
|
+
"if provided",
|
|
348
|
+
"when provided",
|
|
349
|
+
)
|
|
350
|
+
requirement_markers = (
|
|
351
|
+
"include",
|
|
352
|
+
"cite",
|
|
353
|
+
"reference",
|
|
354
|
+
"use",
|
|
355
|
+
"mention",
|
|
356
|
+
"based on",
|
|
357
|
+
"grounded in",
|
|
358
|
+
"with",
|
|
359
|
+
"from",
|
|
360
|
+
)
|
|
361
|
+
for phrase in phrases:
|
|
362
|
+
start = task_lower.find(phrase)
|
|
363
|
+
while start != -1:
|
|
364
|
+
window_start = max(0, start - 48)
|
|
365
|
+
window_end = min(len(task_lower), start + len(phrase) + 72)
|
|
366
|
+
window = task_lower[window_start:window_end]
|
|
367
|
+
if any(marker in window for marker in negation_markers):
|
|
368
|
+
start = task_lower.find(phrase, start + len(phrase))
|
|
369
|
+
continue
|
|
370
|
+
if any(marker in window for marker in requirement_markers):
|
|
371
|
+
return True
|
|
372
|
+
start = task_lower.find(phrase, start + len(phrase))
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _contains_file_path(text: str) -> bool:
|
|
377
|
+
"""Detect source-file path evidence in KB snippets or source names."""
|
|
378
|
+
if not text:
|
|
379
|
+
return False
|
|
380
|
+
return bool(_FILE_PATH_RE.search(text) or _ABSOLUTE_PATH_RE.search(text))
|
|
381
|
+
|
|
382
|
+
@classmethod
|
|
383
|
+
def _search_query_from_task(cls, task: str) -> str:
|
|
384
|
+
"""Drop guardrail-only clauses before KB retrieval.
|
|
385
|
+
|
|
386
|
+
Phrases like "avoid GitHub issue claims unless available" constrain the
|
|
387
|
+
output, but they are not the topic. Keeping them in the search query can
|
|
388
|
+
swamp product terms and retrieve issue-tracking docs for unrelated asks.
|
|
389
|
+
"""
|
|
390
|
+
evidence_phrases = ("github issue", "real issue", "pain point")
|
|
391
|
+
clauses = re.split(r"(?<=[.!?])\s+", task)
|
|
392
|
+
kept: list[str] = []
|
|
393
|
+
for clause in clauses:
|
|
394
|
+
lower = clause.lower()
|
|
395
|
+
if any(phrase in lower for phrase in evidence_phrases) and not cls._requires_evidence(
|
|
396
|
+
lower, evidence_phrases
|
|
397
|
+
):
|
|
398
|
+
continue
|
|
399
|
+
kept.append(clause)
|
|
400
|
+
return " ".join(kept).strip() or task
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _normalize_claim(value: str) -> str:
|
|
404
|
+
return re.sub(r"[^a-z0-9]+", "", value.lower())
|
|
405
|
+
|
|
406
|
+
def _is_evidenced(self, value: str, evidence_text: str) -> bool:
|
|
407
|
+
if not value:
|
|
408
|
+
return False
|
|
409
|
+
normalized_value = self._normalize_claim(value)
|
|
410
|
+
normalized_evidence = self._normalize_claim(evidence_text)
|
|
411
|
+
return bool(normalized_value and normalized_value in normalized_evidence)
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _extract_file_paths(text: str) -> list[str]:
|
|
415
|
+
paths = [match.group(1).strip("`'\".,);]") for match in _FILE_PATH_RE.finditer(text)]
|
|
416
|
+
paths.extend(
|
|
417
|
+
match.group(1).strip("`'\".,);]") for match in _ABSOLUTE_PATH_RE.finditer(text)
|
|
418
|
+
)
|
|
419
|
+
return paths
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _sanitize_internal_markers(text: str) -> str:
|
|
423
|
+
"""Remove generation-context leakage such as '(evidence truncated)'."""
|
|
424
|
+
cleaned = _INTERNAL_MARKER_RE.sub("", text)
|
|
425
|
+
cleaned = re.sub(r"[ \t]+([.,;:])", r"\1", cleaned)
|
|
426
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
|
427
|
+
return cleaned.strip()
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _normalize_unsupported_placeholders(text: str) -> str:
|
|
431
|
+
"""Turn config-looking placeholder constants into reader placeholders."""
|
|
432
|
+
|
|
433
|
+
def replace(match: re.Match[str]) -> str:
|
|
434
|
+
value = match.group(0)
|
|
435
|
+
if "API_KEY" in value:
|
|
436
|
+
return "<your-api-key>"
|
|
437
|
+
if "TOKEN" in value:
|
|
438
|
+
return "<your-token>"
|
|
439
|
+
if "PROJECT" in value:
|
|
440
|
+
return "<project-id>"
|
|
441
|
+
return "<value>"
|
|
442
|
+
|
|
443
|
+
return _UNSUPPORTED_PLACEHOLDER_RE.sub(replace, text)
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _source_citation_lines(content: str) -> list[str]:
|
|
447
|
+
lines = content.splitlines()
|
|
448
|
+
citation_lines = [match.group(1) for match in _SOURCE_LABEL_RE.finditer(content)]
|
|
449
|
+
in_sources = False
|
|
450
|
+
for line in lines:
|
|
451
|
+
if _SOURCE_HEADING_RE.match(line):
|
|
452
|
+
in_sources = True
|
|
453
|
+
continue
|
|
454
|
+
if in_sources and line.startswith("#"):
|
|
455
|
+
in_sources = False
|
|
456
|
+
if in_sources and line.strip():
|
|
457
|
+
citation_lines.append(line)
|
|
458
|
+
return citation_lines
|
|
459
|
+
|
|
460
|
+
def _grounded_output_issues(
|
|
461
|
+
self,
|
|
462
|
+
content: str,
|
|
463
|
+
evidence_text: str,
|
|
464
|
+
*,
|
|
465
|
+
allowed_source_ids: list[str] | None = None,
|
|
466
|
+
task: str = "",
|
|
467
|
+
) -> list[dict[str, str]]:
|
|
468
|
+
"""Find high-risk execution claims that are unsupported by evidence.
|
|
469
|
+
|
|
470
|
+
This is intentionally conservative and deterministic. It focuses on
|
|
471
|
+
failure modes that make content non-executable: invented helper APIs,
|
|
472
|
+
unverified MCP tool names, undocumented scripts/imports, ungrounded REST
|
|
473
|
+
endpoints, invented settings/log paths/tables, source-citation drift,
|
|
474
|
+
and native ClickHouse system tables presented as normal HogQL.
|
|
475
|
+
"""
|
|
476
|
+
issues: list[dict[str, str]] = []
|
|
477
|
+
allowed_sources = set(allowed_source_ids or [])
|
|
478
|
+
content_lower = content.lower()
|
|
479
|
+
|
|
480
|
+
def add(severity: str, issue: str, fix: str) -> None:
|
|
481
|
+
if not any(existing["issue"] == issue for existing in issues):
|
|
482
|
+
issues.append({"severity": severity, "issue": issue, "fix": fix})
|
|
483
|
+
|
|
484
|
+
if _INTERNAL_MARKER_RE.search(content):
|
|
485
|
+
add(
|
|
486
|
+
"medium",
|
|
487
|
+
"content leaks an internal context-truncation marker",
|
|
488
|
+
"Remove phrases such as '(evidence truncated)' and write clean reader-facing prose.",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
for citation_line in self._source_citation_lines(content):
|
|
492
|
+
for cited_path in sorted(set(self._extract_file_paths(citation_line))):
|
|
493
|
+
if cited_path not in allowed_sources:
|
|
494
|
+
add(
|
|
495
|
+
"high",
|
|
496
|
+
f"content cites `{cited_path}` as a source instead of a KB source id",
|
|
497
|
+
"Cite the KB source id from grounding_sources; mention repo files only as files to inspect.",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if re.search(r"\b\w*mcp_call\s*\(", content) and not self._is_evidenced(
|
|
501
|
+
"mcp_call", evidence_text
|
|
502
|
+
):
|
|
503
|
+
add(
|
|
504
|
+
"high",
|
|
505
|
+
"content uses an unsupported MCP wrapper function",
|
|
506
|
+
"Replace invented MCP helper calls with evidenced REST endpoints, HogQL examples, or prose.",
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
for tool_name in sorted(set(re.findall(r"['\"](posthog:[A-Za-z0-9_-]+)['\"]", content))):
|
|
510
|
+
if not self._is_evidenced(tool_name, evidence_text):
|
|
511
|
+
add(
|
|
512
|
+
"high",
|
|
513
|
+
f"content references unsupported MCP tool `{tool_name}`",
|
|
514
|
+
"Remove the tool call or replace it with an API/table that appears in evidence.",
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
for tool_name in sorted(
|
|
518
|
+
set(re.findall(r"(?i)\bMCP\s+`?([A-Za-z0-9_-]+)`?\s+tool\b", content))
|
|
519
|
+
):
|
|
520
|
+
if not self._is_evidenced(tool_name, evidence_text):
|
|
521
|
+
add(
|
|
522
|
+
"high",
|
|
523
|
+
f"content references unsupported MCP tool `{tool_name}`",
|
|
524
|
+
"Remove the MCP tool reference or replace it with a supported API/table that appears in evidence.",
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
for file_path in sorted(set(self._extract_file_paths(content))):
|
|
528
|
+
if file_path in allowed_sources:
|
|
529
|
+
continue
|
|
530
|
+
if not self._is_evidenced(file_path, evidence_text):
|
|
531
|
+
add(
|
|
532
|
+
"high",
|
|
533
|
+
f"content references unsupported file path `{file_path}`",
|
|
534
|
+
"Remove the path or replace it with a file path present in the evidence.",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
for endpoint in sorted(
|
|
538
|
+
set(re.findall(r"(?<!:)`?(/api/[A-Za-z0-9_/@{}<>.-]+/?)[`'\",)]?", content))
|
|
539
|
+
):
|
|
540
|
+
if not self._is_evidenced(endpoint, evidence_text):
|
|
541
|
+
add(
|
|
542
|
+
"high",
|
|
543
|
+
f"content references unsupported endpoint `{endpoint}`",
|
|
544
|
+
"Use only API paths present in the evidence or describe the request generically.",
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
for config_name in sorted(set(_CONFIG_NAME_RE.findall(content))):
|
|
548
|
+
if not self._is_evidenced(config_name, evidence_text):
|
|
549
|
+
add(
|
|
550
|
+
"medium",
|
|
551
|
+
f"content references unsupported setting or constant `{config_name}`",
|
|
552
|
+
"Remove the setting name or replace it with an evidenced configuration key.",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
for match in _SQL_TABLE_RE.finditer(content):
|
|
556
|
+
table_name = match.group(1)
|
|
557
|
+
if "." not in table_name and "_" not in table_name:
|
|
558
|
+
continue
|
|
559
|
+
line_start = content.rfind("\n", 0, match.start()) + 1
|
|
560
|
+
line_end = content.find("\n", match.start())
|
|
561
|
+
line = content[line_start : line_end if line_end != -1 else len(content)]
|
|
562
|
+
if re.match(
|
|
563
|
+
rf"\s*from\s+{re.escape(table_name)}\s+import\b",
|
|
564
|
+
line,
|
|
565
|
+
flags=re.IGNORECASE,
|
|
566
|
+
):
|
|
567
|
+
continue
|
|
568
|
+
if not self._is_evidenced(table_name, evidence_text):
|
|
569
|
+
add(
|
|
570
|
+
"high",
|
|
571
|
+
f"content references unsupported database table `{table_name}`",
|
|
572
|
+
"Use only table names present in evidence or describe the storage layer generically.",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
for block in _SQL_BLOCK_RE.findall(content):
|
|
576
|
+
for identifier in sorted(set(_SQL_IDENTIFIER_RE.findall(block))):
|
|
577
|
+
ident_lower = identifier.lower()
|
|
578
|
+
if ident_lower in _SQL_IDENTIFIER_SKIP:
|
|
579
|
+
continue
|
|
580
|
+
if "_" not in identifier:
|
|
581
|
+
continue
|
|
582
|
+
if self._is_evidenced(identifier, evidence_text):
|
|
583
|
+
continue
|
|
584
|
+
add(
|
|
585
|
+
"medium",
|
|
586
|
+
f"SQL block references unsupported identifier or column `{identifier}`",
|
|
587
|
+
"Remove the SQL identifier or replace it with a column/function name that appears verbatim in the evidence.",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
task_lower = task.lower()
|
|
591
|
+
if "web analytics" in task_lower:
|
|
592
|
+
diagnostic_web_heading = re.search(
|
|
593
|
+
r"(?im)^#{2,4}\s+(?=.*web analytics)(?=.*(?:diagnos|check|troubleshoot|freshness|live|path|verify))",
|
|
594
|
+
content,
|
|
595
|
+
)
|
|
596
|
+
if not diagnostic_web_heading:
|
|
597
|
+
add(
|
|
598
|
+
"medium",
|
|
599
|
+
"task asks for web analytics coverage but output lacks a dedicated diagnostic web analytics section",
|
|
600
|
+
"Add an actionable web analytics diagnostic section grounded in the provided web analytics evidence.",
|
|
601
|
+
)
|
|
602
|
+
if (
|
|
603
|
+
any("managing-path-cleaning-rules" in source for source in allowed_sources)
|
|
604
|
+
and "path cleaning" not in content_lower
|
|
605
|
+
):
|
|
606
|
+
add(
|
|
607
|
+
"medium",
|
|
608
|
+
"web analytics path-cleaning evidence is available but output does not cover path-cleaning freshness checks",
|
|
609
|
+
"Add a path-cleaning freshness-perception check or remove the unused source from the draft.",
|
|
610
|
+
)
|
|
611
|
+
if (
|
|
612
|
+
any("exploring-live-traffic" in source for source in allowed_sources)
|
|
613
|
+
and "live traffic" not in content_lower
|
|
614
|
+
):
|
|
615
|
+
add(
|
|
616
|
+
"medium",
|
|
617
|
+
"web analytics live-traffic evidence is available but output does not cover live-traffic checks",
|
|
618
|
+
"Add a live-traffic verification step grounded in the web analytics evidence or remove the unused source from the draft.",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
for line in content.splitlines():
|
|
622
|
+
if self._is_dead_end_line(line):
|
|
623
|
+
add(
|
|
624
|
+
"medium",
|
|
625
|
+
"content creates a dead-end by requiring source-code inspection where evidence is insufficient",
|
|
626
|
+
"Remove the required diagnostic step or rewrite it as a limitation; do not force readers to inspect source code to continue.",
|
|
627
|
+
)
|
|
628
|
+
break
|
|
629
|
+
|
|
630
|
+
direct_only_tables = (
|
|
631
|
+
"system.replicas",
|
|
632
|
+
"system.parts",
|
|
633
|
+
"system.replication_queue",
|
|
634
|
+
"system.part_log",
|
|
635
|
+
)
|
|
636
|
+
direct_access_terms = ("direct clickhouse", "clickhouse client", "clickhouse cli")
|
|
637
|
+
content_lower = content.lower()
|
|
638
|
+
for table in direct_only_tables:
|
|
639
|
+
if table in content_lower and not any(
|
|
640
|
+
term in content_lower for term in direct_access_terms
|
|
641
|
+
):
|
|
642
|
+
add(
|
|
643
|
+
"high",
|
|
644
|
+
f"native ClickHouse table `{table}` is not marked as direct ClickHouse access only",
|
|
645
|
+
"Either remove it or explicitly state it requires direct ClickHouse access outside PostHog HogQL.",
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
import_patterns = (
|
|
649
|
+
r"^\s*from\s+([A-Za-z_][A-Za-z0-9_.]*)\s+import\s+",
|
|
650
|
+
r"^\s*import\s+([A-Za-z_][A-Za-z0-9_.]*)",
|
|
651
|
+
)
|
|
652
|
+
for pattern in import_patterns:
|
|
653
|
+
for module in sorted(set(re.findall(pattern, content, flags=re.MULTILINE))):
|
|
654
|
+
if (
|
|
655
|
+
module.startswith(("posthog.", "products."))
|
|
656
|
+
and not self._is_evidenced(f"from {module}", evidence_text)
|
|
657
|
+
and not self._is_evidenced(f"import {module}", evidence_text)
|
|
658
|
+
):
|
|
659
|
+
add(
|
|
660
|
+
"medium",
|
|
661
|
+
f"content imports unsupported internal module `{module}`",
|
|
662
|
+
"Use the module as a referenced file path, not as runnable guidance, unless the import is evidenced.",
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
return issues
|
|
666
|
+
|
|
667
|
+
async def _rewrite_ungrounded_content(
|
|
668
|
+
self,
|
|
669
|
+
*,
|
|
670
|
+
content: str,
|
|
671
|
+
issues: list[dict[str, str]],
|
|
672
|
+
evidence_text: str,
|
|
673
|
+
content_type: str,
|
|
674
|
+
allowed_source_ids: list[str] | None = None,
|
|
675
|
+
) -> str:
|
|
676
|
+
issue_lines = "\n".join(
|
|
677
|
+
f"- {issue['severity']}: {issue['issue']} Fix: {issue['fix']}" for issue in issues
|
|
678
|
+
)
|
|
679
|
+
allowed_sources = "\n".join(f"- {source}" for source in allowed_source_ids or [])
|
|
680
|
+
prompt = f"""Rewrite the draft to remove unsupported execution claims.
|
|
681
|
+
|
|
682
|
+
Hard requirements:
|
|
683
|
+
- Use only APIs, endpoints, tables, imports, scripts, and file paths present in the evidence.
|
|
684
|
+
- Delete invented MCP helpers and MCP tool names unless they appear in evidence.
|
|
685
|
+
- Prefer verified REST endpoints and HogQL tables over wrapper functions.
|
|
686
|
+
- Do not invent environment variables, settings, log paths, table schemas, function signatures, or latency numbers.
|
|
687
|
+
- Treat native ClickHouse system tables as direct ClickHouse access only, not normal PostHog HogQL.
|
|
688
|
+
- If an implementation detail is internal, describe it as a file to inspect rather than a public API to import.
|
|
689
|
+
- Do not create a required diagnostic step that only says to inspect source code because evidence is missing.
|
|
690
|
+
- If evidence is insufficient for a command/schema/query, state the limitation or remove that step.
|
|
691
|
+
- Do not tell readers to inspect, review, or consult source files as a required diagnostic step.
|
|
692
|
+
- Do not create limitation-only diagnostic checks. If a check has no concrete grounded action,
|
|
693
|
+
move it to an evidence-limitation note instead of presenting it as a step.
|
|
694
|
+
- Cite source documents only by the allowed KB source ids below. Do not cite repository file paths as source labels.
|
|
695
|
+
- Return only the revised content.
|
|
696
|
+
|
|
697
|
+
Allowed KB source ids:
|
|
698
|
+
{allowed_sources if allowed_sources else "- No KB source ids were provided."}
|
|
699
|
+
|
|
700
|
+
Grounding issues to fix:
|
|
701
|
+
{issue_lines}
|
|
702
|
+
|
|
703
|
+
Evidence:
|
|
704
|
+
{evidence_text[:12000]}
|
|
705
|
+
|
|
706
|
+
Draft:
|
|
707
|
+
{content}
|
|
708
|
+
"""
|
|
709
|
+
return await self.llm_client.generate(
|
|
710
|
+
system_prompt=self.SYSTEM_PROMPT,
|
|
711
|
+
user_prompt=prompt,
|
|
712
|
+
temperature=0.2,
|
|
713
|
+
max_tokens=5000,
|
|
714
|
+
model="sonnet" if content_type in {"tutorial", "blog_post"} else None,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
@staticmethod
|
|
718
|
+
def _coverage_requirements(task: str, grounding_source_ids: list[str]) -> str:
|
|
719
|
+
requirements: list[str] = []
|
|
720
|
+
task_lower = task.lower()
|
|
721
|
+
if "web analytics" in task_lower:
|
|
722
|
+
requirements.append(
|
|
723
|
+
"- Web analytics: include a dedicated diagnostic section with concrete checks "
|
|
724
|
+
"grounded in the web analytics/query-runner evidence, not only architecture context."
|
|
725
|
+
)
|
|
726
|
+
if any("managing-path-cleaning-rules" in source for source in grounding_source_ids):
|
|
727
|
+
requirements.append(
|
|
728
|
+
"- Web analytics path cleaning: include a freshness-perception check for URL/path "
|
|
729
|
+
"normalization when path-cleaning evidence is available."
|
|
730
|
+
)
|
|
731
|
+
if any("exploring-live-traffic" in source for source in grounding_source_ids):
|
|
732
|
+
requirements.append(
|
|
733
|
+
"- Web analytics live traffic: include a concrete check for whether the live "
|
|
734
|
+
"traffic view is receiving events when live-traffic evidence is available."
|
|
735
|
+
)
|
|
736
|
+
if "lazy computation" in task_lower:
|
|
737
|
+
requirements.append(
|
|
738
|
+
"- Lazy computation: include a dedicated diagnostic section for precomputation, "
|
|
739
|
+
"replication, and verified table/settings evidence."
|
|
740
|
+
)
|
|
741
|
+
if any(source.startswith("web-analytics/") for source in grounding_source_ids):
|
|
742
|
+
requirements.append(
|
|
743
|
+
"- If web-analytics KB docs are cited, use them for at least one actionable "
|
|
744
|
+
"verification or troubleshooting step."
|
|
745
|
+
)
|
|
746
|
+
return "\n".join(dict.fromkeys(requirements))
|
|
747
|
+
|
|
748
|
+
async def _generate_fast_draft(
|
|
749
|
+
self,
|
|
750
|
+
*,
|
|
751
|
+
prompt: str,
|
|
752
|
+
content_type: str,
|
|
753
|
+
) -> tuple[str, list[str], list[str]]:
|
|
754
|
+
fast_prompt = f"""{prompt}
|
|
755
|
+
|
|
756
|
+
## FAST DRAFT MODE
|
|
757
|
+
Produce a concise, publishable first draft in one pass.
|
|
758
|
+
|
|
759
|
+
Hard limits:
|
|
760
|
+
- Keep the piece under 1,400 words unless the task explicitly asks for long-form.
|
|
761
|
+
- Avoid generic operational filler and vague runbook phrases.
|
|
762
|
+
- Do not leak internal notes such as "(evidence truncated)" or "context truncated".
|
|
763
|
+
- Do not select SQL columns or identifiers unless those exact names appear in the evidence.
|
|
764
|
+
- Do not put angle-bracket placeholders inside runnable code blocks. If a placeholder would
|
|
765
|
+
make code invalid, describe the value in prose instead.
|
|
766
|
+
- If evidence does not provide a concrete command, state the limitation instead of inventing one.
|
|
767
|
+
- If the task names multiple domains, include a dedicated actionable section for each domain.
|
|
768
|
+
- Avoid dead-end steps that only redirect readers to source files. Use the evidence to provide
|
|
769
|
+
a self-contained check, or state that the evidence is insufficient for that check.
|
|
770
|
+
- Never tell readers to inspect, review, or consult source files as a required diagnostic step.
|
|
771
|
+
- Do not create limitation-only diagnostic checks. If evidence cannot support a concrete action,
|
|
772
|
+
make it an evidence-limitation note rather than a numbered check.
|
|
773
|
+
"""
|
|
774
|
+
content = await self.llm_client.generate(
|
|
775
|
+
system_prompt=self.SYSTEM_PROMPT,
|
|
776
|
+
user_prompt=fast_prompt,
|
|
777
|
+
temperature=0.2,
|
|
778
|
+
max_tokens=5000,
|
|
779
|
+
model="sonnet" if content_type in {"tutorial", "blog_post"} else None,
|
|
780
|
+
)
|
|
781
|
+
return content, ["fast grounded draft"], []
|
|
782
|
+
|
|
783
|
+
@staticmethod
|
|
784
|
+
def _is_dead_end_line(line: str) -> bool:
|
|
785
|
+
lower = line.lower()
|
|
786
|
+
missing_evidence_markers = (
|
|
787
|
+
"evidence does not",
|
|
788
|
+
"source material does not",
|
|
789
|
+
"does not provide",
|
|
790
|
+
"does not specify",
|
|
791
|
+
"does not verify",
|
|
792
|
+
"not provided",
|
|
793
|
+
"not specified",
|
|
794
|
+
"not verified",
|
|
795
|
+
)
|
|
796
|
+
inspection_terms = ("inspect", "see `", "review `", "consult", "determine")
|
|
797
|
+
return (
|
|
798
|
+
(
|
|
799
|
+
any(marker in lower for marker in missing_evidence_markers)
|
|
800
|
+
and any(term in lower for term in inspection_terms)
|
|
801
|
+
)
|
|
802
|
+
or (
|
|
803
|
+
any(marker in lower for marker in missing_evidence_markers)
|
|
804
|
+
and _FILE_PATH_RE.search(line) is not None
|
|
805
|
+
)
|
|
806
|
+
or (any(marker in lower for marker in missing_evidence_markers) and "mcp tool" in lower)
|
|
807
|
+
or re.search(r"(?i)\bsee\s+`[^`]+`\s+for\s+example", line) is not None
|
|
808
|
+
or re.search(
|
|
809
|
+
r"(?i)\b(?:inspect|review|consult)\s+`?[^`\s]+\.(?:py|md|ts|tsx|js|jsx|xml)`?",
|
|
810
|
+
line,
|
|
811
|
+
)
|
|
812
|
+
is not None
|
|
813
|
+
or ("for deeper investigation" in lower and "consult" in lower and "source" in lower)
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
def _remove_dead_end_lines(self, content: str) -> str:
|
|
817
|
+
"""Replace source-inspection dead ends with a reader-facing limitation."""
|
|
818
|
+
repaired_lines: list[str] = []
|
|
819
|
+
inserted_note = False
|
|
820
|
+
changed = False
|
|
821
|
+
for line in content.splitlines():
|
|
822
|
+
if not self._is_dead_end_line(line):
|
|
823
|
+
repaired_lines.append(line)
|
|
824
|
+
continue
|
|
825
|
+
changed = True
|
|
826
|
+
if not inserted_note:
|
|
827
|
+
repaired_lines.append(
|
|
828
|
+
"> Evidence limitation: the current KB evidence does not verify a "
|
|
829
|
+
"self-contained command, schema, or example for this step."
|
|
830
|
+
)
|
|
831
|
+
inserted_note = True
|
|
832
|
+
if not changed:
|
|
833
|
+
return content
|
|
834
|
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(repaired_lines)).strip()
|
|
835
|
+
|
|
836
|
+
def _remove_unsupported_internal_imports(self, content: str, evidence_text: str) -> str:
|
|
837
|
+
"""Replace runnable internal imports that are not verified by evidence."""
|
|
838
|
+
|
|
839
|
+
def unsupported_modules(text: str) -> list[str]:
|
|
840
|
+
modules: list[str] = []
|
|
841
|
+
for match in _INTERNAL_IMPORT_RE.finditer(text):
|
|
842
|
+
module = match.group(1) or match.group(2) or ""
|
|
843
|
+
if module and not (
|
|
844
|
+
self._is_evidenced(f"from {module}", evidence_text)
|
|
845
|
+
or self._is_evidenced(f"import {module}", evidence_text)
|
|
846
|
+
):
|
|
847
|
+
modules.append(module)
|
|
848
|
+
return sorted(set(modules))
|
|
849
|
+
|
|
850
|
+
def replace_block(match: re.Match[str]) -> str:
|
|
851
|
+
modules = unsupported_modules(match.group(2))
|
|
852
|
+
if not modules:
|
|
853
|
+
return match.group(0)
|
|
854
|
+
names = ", ".join(f"`{module}`" for module in modules[:4])
|
|
855
|
+
return (
|
|
856
|
+
"> Evidence limitation: the current KB evidence does not verify "
|
|
857
|
+
f"a runnable internal import example for {names}."
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
repaired = _CODE_BLOCK_RE.sub(replace_block, content)
|
|
861
|
+
if repaired == content:
|
|
862
|
+
modules = unsupported_modules(content)
|
|
863
|
+
if modules:
|
|
864
|
+
names = ", ".join(f"`{module}`" for module in modules[:4])
|
|
865
|
+
repaired = _INTERNAL_IMPORT_RE.sub(
|
|
866
|
+
"> Evidence limitation: the current KB evidence does not verify "
|
|
867
|
+
f"a runnable internal import example for {names}.",
|
|
868
|
+
content,
|
|
869
|
+
)
|
|
870
|
+
return re.sub(r"\n{3,}", "\n\n", repaired).strip()
|
|
871
|
+
|
|
872
|
+
@staticmethod
|
|
873
|
+
def _remove_invalid_code_blocks(content: str, errors: list[Any]) -> str:
|
|
874
|
+
"""Replace fenced code blocks that failed syntax validation with a limitation note."""
|
|
875
|
+
invalid_blocks = {
|
|
876
|
+
(str(error.block.language).lower(), error.block.code.strip()) for error in errors
|
|
877
|
+
}
|
|
878
|
+
if not invalid_blocks:
|
|
879
|
+
return content
|
|
880
|
+
|
|
881
|
+
def replace(match: re.Match[str]) -> str:
|
|
882
|
+
language = match.group(1).lower().strip()
|
|
883
|
+
code = match.group(2).strip()
|
|
884
|
+
if (language, code) not in invalid_blocks:
|
|
885
|
+
return match.group(0)
|
|
886
|
+
return (
|
|
887
|
+
"> Evidence limitation: this runnable code example was removed because "
|
|
888
|
+
"it failed syntax validation. Use the surrounding verified data model "
|
|
889
|
+
"and source notes instead of copying an invalid snippet."
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
return re.sub(r"\n{3,}", "\n\n", _CODE_BLOCK_RE.sub(replace, content)).strip()
|
|
893
|
+
|
|
894
|
+
@staticmethod
|
|
895
|
+
def _demote_limitation_only_checks(content: str) -> str:
|
|
896
|
+
"""Avoid numbered diagnostic checks that only contain limitations."""
|
|
897
|
+
|
|
898
|
+
def replace(match: re.Match[str]) -> str:
|
|
899
|
+
title = match.group(1).strip()
|
|
900
|
+
body = match.group(2).strip()
|
|
901
|
+
if len(body) < 20:
|
|
902
|
+
return ""
|
|
903
|
+
has_limitation = "Evidence limitation" in body or "Evidence note" in body
|
|
904
|
+
if has_limitation and "```" not in body:
|
|
905
|
+
return (
|
|
906
|
+
f"### Evidence limitation: {title}\n\n"
|
|
907
|
+
"The current KB evidence does not verify a self-contained command, "
|
|
908
|
+
"schema, or example for this diagnostic.\n\n"
|
|
909
|
+
)
|
|
910
|
+
return match.group(0)
|
|
911
|
+
|
|
912
|
+
return re.sub(r"\n{3,}", "\n\n", _CHECK_SECTION_RE.sub(replace, content)).strip()
|
|
913
|
+
|
|
914
|
+
def _remove_unsupported_sql_blocks(self, content: str, evidence_text: str) -> str:
|
|
915
|
+
"""Replace SQL blocks containing unevidenced identifiers with prose.
|
|
916
|
+
|
|
917
|
+
This keeps a draft publishable without shipping copy-paste SQL that
|
|
918
|
+
selected columns or placeholders the evidence did not verify.
|
|
919
|
+
"""
|
|
920
|
+
|
|
921
|
+
def replace(match: re.Match[str]) -> str:
|
|
922
|
+
block = match.group(1)
|
|
923
|
+
unsupported = []
|
|
924
|
+
for identifier in sorted(set(_SQL_IDENTIFIER_RE.findall(block))):
|
|
925
|
+
ident_lower = identifier.lower()
|
|
926
|
+
if ident_lower in _SQL_IDENTIFIER_SKIP or "_" not in identifier:
|
|
927
|
+
continue
|
|
928
|
+
if not self._is_evidenced(identifier, evidence_text):
|
|
929
|
+
unsupported.append(identifier)
|
|
930
|
+
if not unsupported:
|
|
931
|
+
return match.group(0)
|
|
932
|
+
names = ", ".join(f"`{name}`" for name in unsupported[:6])
|
|
933
|
+
return (
|
|
934
|
+
"> Evidence note: the source material does not verify a safe "
|
|
935
|
+
f"copy-paste SQL query for this check because {names} "
|
|
936
|
+
"did not appear in the provided evidence. Treat this as a "
|
|
937
|
+
"current evidence limitation rather than a runnable query."
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
return _SQL_BLOCK_RE.sub(replace, content)
|
|
941
|
+
|
|
942
|
+
@staticmethod
|
|
943
|
+
def _code_validation_payload(report: Any) -> dict[str, Any]:
|
|
944
|
+
return {
|
|
945
|
+
"total_blocks": report.total_blocks,
|
|
946
|
+
"validated": report.validated,
|
|
947
|
+
"passed": report.passed,
|
|
948
|
+
"failed": report.failed,
|
|
949
|
+
"skipped": report.skipped,
|
|
950
|
+
"all_passed": report.all_passed,
|
|
951
|
+
"errors": [
|
|
952
|
+
{
|
|
953
|
+
"language": e.block.language,
|
|
954
|
+
"line": e.block.line_number,
|
|
955
|
+
"error": e.error,
|
|
956
|
+
"code_snippet": e.block.code[:200],
|
|
957
|
+
}
|
|
958
|
+
for e in report.errors
|
|
959
|
+
],
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async def execute(
|
|
963
|
+
self,
|
|
964
|
+
task: str,
|
|
965
|
+
context: Optional[dict[str, Any]] = None,
|
|
966
|
+
content_type: str = "tutorial",
|
|
967
|
+
editorial_mode: str = "pipeline",
|
|
968
|
+
) -> dict[str, Any]:
|
|
969
|
+
"""
|
|
970
|
+
Execute a content creation task.
|
|
971
|
+
|
|
972
|
+
Uses LLMClient to generate content grounded in the knowledge base,
|
|
973
|
+
with cross-agent context informing topic selection and framing.
|
|
974
|
+
|
|
975
|
+
Grounding sources (in priority order):
|
|
976
|
+
1. Knowledge base files (curated product docs)
|
|
977
|
+
2. Dex's architecture analysis (actual repo structure)
|
|
978
|
+
3. Iris's pain points (what developers actually struggle with)
|
|
979
|
+
4. Sage's real issues (concrete GitHub issue titles/numbers)
|
|
980
|
+
5. Official docs via GitMCP (live documentation)
|
|
981
|
+
"""
|
|
982
|
+
logger.info(f"Kai executing: {task[:80]}...")
|
|
983
|
+
|
|
984
|
+
# 1. Search knowledge base — cap total context to ~12K chars
|
|
985
|
+
search_query = self._search_query_from_task(task)
|
|
986
|
+
raw_grounding_docs = self.search_knowledge_base(
|
|
987
|
+
search_query,
|
|
988
|
+
max_results=5,
|
|
989
|
+
content_truncate=5000,
|
|
990
|
+
)
|
|
991
|
+
task_lower = task.lower()
|
|
992
|
+
grounding_docs = [
|
|
993
|
+
doc
|
|
994
|
+
for doc in raw_grounding_docs
|
|
995
|
+
if float(doc.get("relevance", 0) or 0) > 0
|
|
996
|
+
or (
|
|
997
|
+
self._requires_evidence(task_lower, ("file path", "source code"))
|
|
998
|
+
and self._contains_file_path(f"{doc.get('content', '')}\n{doc.get('source', '')}")
|
|
999
|
+
)
|
|
1000
|
+
]
|
|
1001
|
+
per_doc_budget = max(1200, 11500 // max(len(grounding_docs), 1))
|
|
1002
|
+
grounding_context = "\n\n".join(
|
|
1003
|
+
f"[Source: {doc['source']}]\n{str(doc['content'])[:per_doc_budget]}"
|
|
1004
|
+
for doc in grounding_docs
|
|
1005
|
+
)[:12000]
|
|
1006
|
+
grounding_source_ids = [doc["source"] for doc in grounding_docs]
|
|
1007
|
+
citation_source_section = "\n".join(f"- {source}" for source in grounding_source_ids)
|
|
1008
|
+
coverage_section = self._coverage_requirements(task, grounding_source_ids)
|
|
1009
|
+
|
|
1010
|
+
# 2. Fetch official docs from GitMCP (capped)
|
|
1011
|
+
official_docs = ""
|
|
1012
|
+
if self.search_tools:
|
|
1013
|
+
try:
|
|
1014
|
+
raw_docs = await self.search_tools.fetch_official_docs(search_query)
|
|
1015
|
+
official_docs = (raw_docs or "")[:4000]
|
|
1016
|
+
except Exception as exc:
|
|
1017
|
+
logger.warning(f"Official docs fetch failed: {exc}")
|
|
1018
|
+
|
|
1019
|
+
# 3. Extract structured upstream context
|
|
1020
|
+
upstream = self._extract_upstream_context(context)
|
|
1021
|
+
pain_points = upstream["pain_points"]
|
|
1022
|
+
real_issues = upstream["real_issues"]
|
|
1023
|
+
arch_doc = upstream["architecture_doc"]
|
|
1024
|
+
dex_summary = upstream["dex_summary"]
|
|
1025
|
+
source_files = upstream["source_files"]
|
|
1026
|
+
api_paths = upstream["api_paths"]
|
|
1027
|
+
content_brief = upstream["content_brief"]
|
|
1028
|
+
|
|
1029
|
+
# Build pain points section
|
|
1030
|
+
pain_section = ""
|
|
1031
|
+
if pain_points:
|
|
1032
|
+
pain_section = "Top developer pain points (from community feedback this week):\n"
|
|
1033
|
+
for pp in pain_points[:5]:
|
|
1034
|
+
pain_section += (
|
|
1035
|
+
f"- **{pp['title']}** (severity: {pp['severity']}, "
|
|
1036
|
+
f"category: {pp['category']}): {pp['description'][:200]}\n"
|
|
1037
|
+
)
|
|
1038
|
+
if pp["issues"]:
|
|
1039
|
+
pain_section += (
|
|
1040
|
+
f" Related GitHub issues: {', '.join(f'#{i}' for i in pp['issues'][:3])}\n"
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
# Build real issues section
|
|
1044
|
+
issues_section = ""
|
|
1045
|
+
if real_issues:
|
|
1046
|
+
issues_section = "Real GitHub issues developers filed this week:\n"
|
|
1047
|
+
for issue in real_issues[:8]:
|
|
1048
|
+
issues_section += (
|
|
1049
|
+
f"- #{issue['number']}: {issue['title']} [{issue['product_area']}]\n"
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
# Build dedup section from cross-run memory
|
|
1053
|
+
prev_titles = upstream["previous_content_titles"]
|
|
1054
|
+
recurring = upstream["recurring_themes"]
|
|
1055
|
+
dedup_section = ""
|
|
1056
|
+
if prev_titles:
|
|
1057
|
+
dedup_section += "Content already produced in recent weeks (DO NOT repeat):\n"
|
|
1058
|
+
for t in prev_titles[:10]:
|
|
1059
|
+
dedup_section += f"- {t}\n"
|
|
1060
|
+
if recurring:
|
|
1061
|
+
unique_recurring = list(dict.fromkeys(recurring))[:5]
|
|
1062
|
+
dedup_section += "\nRecurring themes across weeks (consider deeper coverage):\n"
|
|
1063
|
+
for t in unique_recurring:
|
|
1064
|
+
dedup_section += f"- {t}\n"
|
|
1065
|
+
|
|
1066
|
+
source_section = ""
|
|
1067
|
+
if source_files:
|
|
1068
|
+
source_section = "Repository files Dex identified as usable evidence:\n"
|
|
1069
|
+
for item in source_files[:12]:
|
|
1070
|
+
symbols = ", ".join(str(s) for s in item.get("symbols", [])[:5])
|
|
1071
|
+
detail = f" — {symbols}" if symbols else ""
|
|
1072
|
+
source_section += f"- {item.get('path', '')}{detail}\n"
|
|
1073
|
+
if api_paths:
|
|
1074
|
+
source_section += "\nAPI/reference paths Dex identified:\n"
|
|
1075
|
+
for path in api_paths[:12]:
|
|
1076
|
+
source_section += f"- {path}\n"
|
|
1077
|
+
|
|
1078
|
+
brief_section = ""
|
|
1079
|
+
if content_brief:
|
|
1080
|
+
brief_section = json.dumps(content_brief, indent=2, default=str)[:4000]
|
|
1081
|
+
|
|
1082
|
+
prompt = f"""Task: {task}
|
|
1083
|
+
|
|
1084
|
+
## Knowledge Base (AUTHORITATIVE — use these as ground truth)
|
|
1085
|
+
{grounding_context if grounding_context else "No specific docs found."}
|
|
1086
|
+
|
|
1087
|
+
## Allowed Citation Source IDs
|
|
1088
|
+
{citation_source_section if citation_source_section else "No knowledge base source ids available."}
|
|
1089
|
+
|
|
1090
|
+
## Repository Architecture (from source code analysis)
|
|
1091
|
+
{arch_doc if arch_doc else "No architecture analysis available."}
|
|
1092
|
+
{f"Summary: {dex_summary}" if dex_summary else ""}
|
|
1093
|
+
|
|
1094
|
+
## Source Evidence
|
|
1095
|
+
{source_section if source_section else "No source file evidence available."}
|
|
1096
|
+
|
|
1097
|
+
## Official Documentation Reference
|
|
1098
|
+
{official_docs if official_docs else "No official docs fetched."}
|
|
1099
|
+
|
|
1100
|
+
## Content Brief
|
|
1101
|
+
{brief_section if brief_section else "No explicit content brief available."}
|
|
1102
|
+
|
|
1103
|
+
## Required Coverage
|
|
1104
|
+
{coverage_section if coverage_section else "No additional domain coverage requirements."}
|
|
1105
|
+
|
|
1106
|
+
## Community Context
|
|
1107
|
+
{pain_section if pain_section else "No pain point data from upstream agents."}
|
|
1108
|
+
|
|
1109
|
+
{issues_section if issues_section else ""}
|
|
1110
|
+
|
|
1111
|
+
## Content History
|
|
1112
|
+
{dedup_section if dedup_section else "No previous content history available."}
|
|
1113
|
+
|
|
1114
|
+
## CRITICAL INSTRUCTIONS
|
|
1115
|
+
1. Every fact, command, file path, API endpoint, and code example MUST come from
|
|
1116
|
+
the Knowledge Base or Repository Architecture sections above. If you cannot find
|
|
1117
|
+
it in the context provided, do NOT invent it.
|
|
1118
|
+
2. Use REAL installation commands from the knowledge base (e.g., the actual install
|
|
1119
|
+
script URL, actual CLI commands, actual configuration keys).
|
|
1120
|
+
3. Reference REAL file paths and directory structures only when they appear in the
|
|
1121
|
+
evidence above. Treat repo paths as files to inspect, not source-citation labels.
|
|
1122
|
+
4. When showing code examples, base them on actual patterns from the source code.
|
|
1123
|
+
Prefer documented REST endpoints, HogQL queries, and existing file paths over
|
|
1124
|
+
invented helper functions. Never invent MCP tool names or wrapper functions.
|
|
1125
|
+
5. Address a community pain point only when one is present in the Community Context.
|
|
1126
|
+
6. Reference GitHub issue titles/numbers only when real issues are present in the
|
|
1127
|
+
Community Context. If none are present, omit issue references entirely.
|
|
1128
|
+
7. Structure: Prerequisites → Step-by-step → Verification → Troubleshooting → Next Steps.
|
|
1129
|
+
8. Cite only the allowed KB source ids listed above. Use those ids exactly; do not
|
|
1130
|
+
cite repository file paths as standalone sources.
|
|
1131
|
+
9. Do NOT hallucinate URLs, endpoints, or configuration options that aren't in the context.
|
|
1132
|
+
10. Do NOT repeat topics from the Content History section — pick a fresh angle or go deeper.
|
|
1133
|
+
11. If a theme keeps recurring across weeks, produce advanced/deep-dive content instead of intro-level.
|
|
1134
|
+
12. If you mention a native ClickHouse system table such as system.replicas,
|
|
1135
|
+
system.parts, system.replication_queue, or system.part_log, clearly mark it
|
|
1136
|
+
as direct ClickHouse access only, not a PostHog HogQL table.
|
|
1137
|
+
13. Do NOT invent log file paths, environment variables, Django settings, table schemas,
|
|
1138
|
+
function signatures, or latency/capacity numbers. If the evidence does not specify
|
|
1139
|
+
them, say the evidence does not specify them.
|
|
1140
|
+
14. For maintainer diagnostics, attach a KB source id to each concrete path, table,
|
|
1141
|
+
setting, endpoint, or command claim so the reader can verify it.
|
|
1142
|
+
15. Never output internal context-management notes such as "evidence truncated",
|
|
1143
|
+
"context truncated", or similar meta commentary.
|
|
1144
|
+
16. If SQL examples include selected columns, every non-generic column or identifier
|
|
1145
|
+
must appear verbatim in the evidence. Otherwise state the evidence limitation in prose.
|
|
1146
|
+
17. Satisfy every Required Coverage bullet with a concrete section in the draft.
|
|
1147
|
+
18. Do not tell readers to inspect, review, or consult source files as a required diagnostic
|
|
1148
|
+
step. If the evidence is missing details, state that limitation without turning it into
|
|
1149
|
+
a required step.
|
|
1150
|
+
19. Do not create limitation-only diagnostic checks. If a check has no concrete grounded action,
|
|
1151
|
+
move it to an evidence-limitation note instead of presenting it as a step.
|
|
1152
|
+
20. Do not put angle-bracket placeholders inside runnable code blocks. If a placeholder would
|
|
1153
|
+
make code invalid, describe the value in prose instead.
|
|
1154
|
+
"""
|
|
1155
|
+
|
|
1156
|
+
base_result = {
|
|
1157
|
+
"agent": "kai",
|
|
1158
|
+
"task": task,
|
|
1159
|
+
"grounding_sources": grounding_source_ids,
|
|
1160
|
+
"pain_points_addressed": [pp["title"] for pp in pain_points[:3]],
|
|
1161
|
+
"real_issues_referenced": [i["number"] for i in real_issues[:5]],
|
|
1162
|
+
"status": "generated",
|
|
1163
|
+
"editorial_mode": editorial_mode,
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
evidence_gaps = self._evidence_gaps(
|
|
1167
|
+
task,
|
|
1168
|
+
grounding_docs=grounding_docs,
|
|
1169
|
+
official_docs=official_docs,
|
|
1170
|
+
upstream=upstream,
|
|
1171
|
+
)
|
|
1172
|
+
if evidence_gaps:
|
|
1173
|
+
base_result["status"] = "insufficient_evidence"
|
|
1174
|
+
base_result["evidence_gaps"] = evidence_gaps
|
|
1175
|
+
base_result["prompt_used"] = prompt[:500]
|
|
1176
|
+
return base_result
|
|
1177
|
+
|
|
1178
|
+
if self.llm_client:
|
|
1179
|
+
try:
|
|
1180
|
+
mode = editorial_mode.strip().lower()
|
|
1181
|
+
if mode in {"fast", "direct"}:
|
|
1182
|
+
content, strengths, issues = await self._generate_fast_draft(
|
|
1183
|
+
prompt=prompt,
|
|
1184
|
+
content_type=content_type,
|
|
1185
|
+
)
|
|
1186
|
+
else:
|
|
1187
|
+
content, strengths, issues = await generate_with_pipeline(
|
|
1188
|
+
llm_client=self.llm_client,
|
|
1189
|
+
system_prompt=self.SYSTEM_PROMPT,
|
|
1190
|
+
user_prompt=prompt,
|
|
1191
|
+
content_type=content_type,
|
|
1192
|
+
logger=logger,
|
|
1193
|
+
)
|
|
1194
|
+
base_result["content"] = content
|
|
1195
|
+
if issues and isinstance(issues[0], dict):
|
|
1196
|
+
remaining_issues = [
|
|
1197
|
+
i for i in issues if isinstance(i, dict) and i.get("severity") == "high"
|
|
1198
|
+
]
|
|
1199
|
+
else:
|
|
1200
|
+
remaining_issues = [i for i in issues if isinstance(i, str) and i.strip()]
|
|
1201
|
+
base_result["revision"] = {
|
|
1202
|
+
"strengths": strengths,
|
|
1203
|
+
"remaining_issues": remaining_issues,
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
evidence_text = "\n\n".join(
|
|
1207
|
+
[
|
|
1208
|
+
grounding_context,
|
|
1209
|
+
arch_doc,
|
|
1210
|
+
dex_summary,
|
|
1211
|
+
source_section,
|
|
1212
|
+
official_docs,
|
|
1213
|
+
brief_section,
|
|
1214
|
+
coverage_section,
|
|
1215
|
+
]
|
|
1216
|
+
)
|
|
1217
|
+
content = self._normalize_unsupported_placeholders(
|
|
1218
|
+
self._sanitize_internal_markers(content)
|
|
1219
|
+
)
|
|
1220
|
+
content = self._demote_limitation_only_checks(content)
|
|
1221
|
+
base_result["content"] = content
|
|
1222
|
+
grounding_issues = self._grounded_output_issues(
|
|
1223
|
+
content,
|
|
1224
|
+
evidence_text,
|
|
1225
|
+
allowed_source_ids=grounding_source_ids,
|
|
1226
|
+
task=task,
|
|
1227
|
+
)
|
|
1228
|
+
if grounding_issues:
|
|
1229
|
+
rewritten = await self._rewrite_ungrounded_content(
|
|
1230
|
+
content=content,
|
|
1231
|
+
issues=grounding_issues,
|
|
1232
|
+
evidence_text=evidence_text,
|
|
1233
|
+
content_type=content_type,
|
|
1234
|
+
allowed_source_ids=grounding_source_ids,
|
|
1235
|
+
)
|
|
1236
|
+
rewritten = self._normalize_unsupported_placeholders(
|
|
1237
|
+
self._sanitize_internal_markers(rewritten)
|
|
1238
|
+
)
|
|
1239
|
+
rewritten = self._demote_limitation_only_checks(rewritten)
|
|
1240
|
+
rewritten_issues = self._grounded_output_issues(
|
|
1241
|
+
rewritten,
|
|
1242
|
+
evidence_text,
|
|
1243
|
+
allowed_source_ids=grounding_source_ids,
|
|
1244
|
+
task=task,
|
|
1245
|
+
)
|
|
1246
|
+
deterministic_repair = False
|
|
1247
|
+
if rewritten_issues:
|
|
1248
|
+
repaired = self._remove_unsupported_sql_blocks(rewritten, evidence_text)
|
|
1249
|
+
repaired = self._remove_unsupported_internal_imports(
|
|
1250
|
+
repaired,
|
|
1251
|
+
evidence_text,
|
|
1252
|
+
)
|
|
1253
|
+
repaired = self._remove_dead_end_lines(repaired)
|
|
1254
|
+
repaired = self._demote_limitation_only_checks(repaired)
|
|
1255
|
+
if repaired != rewritten:
|
|
1256
|
+
deterministic_repair = True
|
|
1257
|
+
rewritten = self._normalize_unsupported_placeholders(
|
|
1258
|
+
self._sanitize_internal_markers(repaired)
|
|
1259
|
+
)
|
|
1260
|
+
rewritten_issues = self._grounded_output_issues(
|
|
1261
|
+
rewritten,
|
|
1262
|
+
evidence_text,
|
|
1263
|
+
allowed_source_ids=grounding_source_ids,
|
|
1264
|
+
task=task,
|
|
1265
|
+
)
|
|
1266
|
+
base_result["content"] = rewritten
|
|
1267
|
+
base_result["grounding_validation"] = {
|
|
1268
|
+
"rewritten": True,
|
|
1269
|
+
"deterministic_repair": deterministic_repair,
|
|
1270
|
+
"initial_issues": grounding_issues,
|
|
1271
|
+
"remaining_issues": rewritten_issues,
|
|
1272
|
+
"all_passed": not rewritten_issues,
|
|
1273
|
+
}
|
|
1274
|
+
if rewritten_issues:
|
|
1275
|
+
base_result["status"] = "blocked_by_grounding_gate"
|
|
1276
|
+
base_result["content"] = ""
|
|
1277
|
+
logger.warning(
|
|
1278
|
+
"Kai grounding gate blocked content after rewrite: %s",
|
|
1279
|
+
rewritten_issues,
|
|
1280
|
+
)
|
|
1281
|
+
else:
|
|
1282
|
+
base_result["grounding_validation"] = {
|
|
1283
|
+
"rewritten": False,
|
|
1284
|
+
"initial_issues": [],
|
|
1285
|
+
"remaining_issues": [],
|
|
1286
|
+
"all_passed": True,
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
code_repair = False
|
|
1290
|
+
report = self.code_validator.validate_content(base_result.get("content", ""))
|
|
1291
|
+
if not report.all_passed:
|
|
1292
|
+
repaired = self._remove_invalid_code_blocks(
|
|
1293
|
+
base_result.get("content", ""),
|
|
1294
|
+
report.errors,
|
|
1295
|
+
)
|
|
1296
|
+
if repaired != base_result.get("content", ""):
|
|
1297
|
+
code_repair = True
|
|
1298
|
+
base_result["content"] = repaired
|
|
1299
|
+
report = self.code_validator.validate_content(repaired)
|
|
1300
|
+
code_payload = self._code_validation_payload(report)
|
|
1301
|
+
code_payload["deterministic_repair"] = code_repair
|
|
1302
|
+
base_result["code_validation"] = code_payload
|
|
1303
|
+
if not report.all_passed:
|
|
1304
|
+
logger.warning(
|
|
1305
|
+
f"Code validation: {report.failed}/{report.validated} "
|
|
1306
|
+
f"blocks failed syntax checks"
|
|
1307
|
+
)
|
|
1308
|
+
base_result["status"] = "blocked_by_code_validation"
|
|
1309
|
+
base_result["content"] = ""
|
|
1310
|
+
except AbortLoud as exc:
|
|
1311
|
+
logger.warning("Content generation blocked by quality gate: %s", exc)
|
|
1312
|
+
base_result["status"] = "blocked_by_quality_gate"
|
|
1313
|
+
base_result["error"] = str(exc)
|
|
1314
|
+
base_result["content"] = ""
|
|
1315
|
+
base_result["prompt_used"] = prompt[:500]
|
|
1316
|
+
except Exception as exc:
|
|
1317
|
+
logger.exception(f"Content generation failed: {exc}")
|
|
1318
|
+
base_result["status"] = "error"
|
|
1319
|
+
base_result["error"] = str(exc)
|
|
1320
|
+
base_result.setdefault("content", "")
|
|
1321
|
+
base_result["prompt_used"] = prompt[:500]
|
|
1322
|
+
else:
|
|
1323
|
+
base_result["prompt_used"] = prompt[:500]
|
|
1324
|
+
|
|
1325
|
+
return base_result
|
|
1326
|
+
|
|
1327
|
+
async def write_tutorial(
|
|
1328
|
+
self,
|
|
1329
|
+
topic: str,
|
|
1330
|
+
target_sdk: str = "javascript",
|
|
1331
|
+
context: Optional[dict[str, Any]] = None,
|
|
1332
|
+
content_type: str = "tutorial",
|
|
1333
|
+
) -> ContentPiece:
|
|
1334
|
+
"""Generate a step-by-step technical tutorial."""
|
|
1335
|
+
task = (
|
|
1336
|
+
f"Write a step-by-step tutorial on: {topic}. "
|
|
1337
|
+
f"Target SDK: {target_sdk}. "
|
|
1338
|
+
f"Include prerequisites, working code examples, and next steps."
|
|
1339
|
+
)
|
|
1340
|
+
result = await self.execute(task, context, content_type=content_type)
|
|
1341
|
+
return ContentPiece(
|
|
1342
|
+
title=topic,
|
|
1343
|
+
content_type="tutorial",
|
|
1344
|
+
body=result.get("content", ""),
|
|
1345
|
+
metadata={"sdk": target_sdk, "word_count_target": 2000},
|
|
1346
|
+
grounding_sources=result.get("grounding_sources", []),
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
async def write_changelog(
|
|
1350
|
+
self,
|
|
1351
|
+
feature_name: str,
|
|
1352
|
+
context: Optional[dict[str, Any]] = None,
|
|
1353
|
+
content_type: str = "landing_page",
|
|
1354
|
+
) -> ContentPiece:
|
|
1355
|
+
"""Generate a changelog announcement for a new feature."""
|
|
1356
|
+
task = (
|
|
1357
|
+
f"Write a changelog announcement for: {feature_name}. "
|
|
1358
|
+
f"Cover what changed, why it matters, and how to use it."
|
|
1359
|
+
)
|
|
1360
|
+
result = await self.execute(task, context, content_type=content_type)
|
|
1361
|
+
return ContentPiece(
|
|
1362
|
+
title=f"New: {feature_name}",
|
|
1363
|
+
content_type="changelog",
|
|
1364
|
+
body=result.get("content", ""),
|
|
1365
|
+
metadata={"word_count_target": 300},
|
|
1366
|
+
grounding_sources=result.get("grounding_sources", []),
|
|
1367
|
+
)
|