hukair-codetalk 0.3.1__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.
- codetalk/__init__.py +7 -0
- codetalk/__main__.py +5 -0
- codetalk/adr_export.py +137 -0
- codetalk/agent_seed.md +18 -0
- codetalk/align.py +42 -0
- codetalk/ask.py +278 -0
- codetalk/blame.py +170 -0
- codetalk/brief.py +218 -0
- codetalk/briefing.py +272 -0
- codetalk/cache.py +259 -0
- codetalk/cache_capsules.py +84 -0
- codetalk/chat.py +117 -0
- codetalk/cli.py +181 -0
- codetalk/codex_sessions.py +223 -0
- codetalk/commands.py +177 -0
- codetalk/commands_view.py +170 -0
- codetalk/config.py +138 -0
- codetalk/console.html +1169 -0
- codetalk/console.py +155 -0
- codetalk/conversation.py +75 -0
- codetalk/course.html +292 -0
- codetalk/course.py +209 -0
- codetalk/cursor_sessions.py +297 -0
- codetalk/debt.py +87 -0
- codetalk/demo.py +72 -0
- codetalk/digest.py +262 -0
- codetalk/doctor.py +123 -0
- codetalk/drift.py +99 -0
- codetalk/enrich.py +281 -0
- codetalk/enrich_plan.py +144 -0
- codetalk/filetree.py +98 -0
- codetalk/fts.py +122 -0
- codetalk/gitlog.py +294 -0
- codetalk/graph.html +228 -0
- codetalk/graph.py +192 -0
- codetalk/grounding_render.py +15 -0
- codetalk/highlight.py +43 -0
- codetalk/hook.py +92 -0
- codetalk/llm.py +264 -0
- codetalk/mcp_server.py +116 -0
- codetalk/mcp_tools.py +216 -0
- codetalk/obsidian.py +62 -0
- codetalk/prompts.py +113 -0
- codetalk/prompts_view.py +87 -0
- codetalk/report.py +182 -0
- codetalk/retrieval.py +72 -0
- codetalk/review.html +299 -0
- codetalk/review.py +277 -0
- codetalk/review_diff.py +41 -0
- codetalk/review_feedback.py +71 -0
- codetalk/review_web.py +226 -0
- codetalk/search.py +75 -0
- codetalk/self_report.py +149 -0
- codetalk/sessions.py +245 -0
- codetalk/trust_ab.html +146 -0
- codetalk/tunnel.html +412 -0
- codetalk/tunnel.py +112 -0
- codetalk/web.py +291 -0
- codetalk/web_chat.html +352 -0
- codetalk/webserve.py +83 -0
- hukair_codetalk-0.3.1.dist-info/METADATA +360 -0
- hukair_codetalk-0.3.1.dist-info/RECORD +66 -0
- hukair_codetalk-0.3.1.dist-info/WHEEL +5 -0
- hukair_codetalk-0.3.1.dist-info/entry_points.txt +2 -0
- hukair_codetalk-0.3.1.dist-info/licenses/LICENSE +661 -0
- hukair_codetalk-0.3.1.dist-info/top_level.txt +1 -0
codetalk/__init__.py
ADDED
codetalk/__main__.py
ADDED
codetalk/adr_export.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""ADR 导出(零 LLM):把一段代码的真实决策史确定性渲染成 MADR / Nygard / CycloneDX。
|
|
2
|
+
|
|
3
|
+
区别于手写 ADR 生态(adr-tools / Log4brains / pyadr 全手写、无一从 git 自动挖):codetalk 从
|
|
4
|
+
真实 commit/决策**自动**导出,「来源」段附真实 commit SHA + 逐字原话(可核验,非 LLM 反推)——
|
|
5
|
+
「别人要你手写 ADR,codetalk 从真实记录自动导出且逐字接地」。复用 blame.collect_segments;
|
|
6
|
+
纯字符串拼接、零 LLM、不触网、出口脱敏、绝不崩。
|
|
7
|
+
|
|
8
|
+
cyclonedx 格式输出 CycloneDX 1.5 base schema 子集(bomFormat/specVersion/components),
|
|
9
|
+
让决策史能接进 AIBOM 生态(CISA/G7 SBOM for AI、CycloneDX AI 扩展)。timestamp 取最新
|
|
10
|
+
commit 时间保证 reproducible(同输入同输出),不假装符合 modelCard/formulation 等 AI 专门段
|
|
11
|
+
——codetalk 跟踪的是代码决策不是模型,硬塞会编造。
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .config import redact_data, redact_secrets
|
|
19
|
+
|
|
20
|
+
FORMATS = ("madr", "nygard", "cyclonedx")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bullets(items):
|
|
24
|
+
uniq = [str(x) for x in dict.fromkeys(items) if str(x).strip()]
|
|
25
|
+
return ["- " + x for x in uniq] or ["-(未记)"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _to_cyclonedx(target, segments):
|
|
29
|
+
"""CycloneDX 1.5 base schema 子集——每个 commit 一个 component,逐字保留决策原话。
|
|
30
|
+
serialNumber/timestamp 据 target+segments 哈希确定性生成,同输入字节级 reproducible。"""
|
|
31
|
+
latest_ts = ""
|
|
32
|
+
for s in reversed(segments):
|
|
33
|
+
if s.get("date"):
|
|
34
|
+
latest_ts = s["date"]
|
|
35
|
+
break
|
|
36
|
+
# uuid5(确定性:同输入同 UUID,仍字节级 reproducible)且是合法 RFC-4122 v5——
|
|
37
|
+
# 裸 sha256 切片虽过 CycloneDX 宽松 regex,但 version/variant nibble 不合规,严格校验会拒。
|
|
38
|
+
seed = target + "|" + "|".join(s.get("sha", "") for s in segments)
|
|
39
|
+
serial = f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, seed)}"
|
|
40
|
+
components = []
|
|
41
|
+
for s in segments:
|
|
42
|
+
props = []
|
|
43
|
+
for d in (s.get("decisions") or []):
|
|
44
|
+
props.append({"name": "codetalk:decision", "value": d})
|
|
45
|
+
for r in (s.get("rejected") or []):
|
|
46
|
+
props.append({"name": "codetalk:rejected", "value": r})
|
|
47
|
+
for r in (s.get("risks") or []):
|
|
48
|
+
props.append({"name": "codetalk:risk", "value": r})
|
|
49
|
+
components.append({
|
|
50
|
+
"type": "data", "bom-ref": s.get("sha", ""),
|
|
51
|
+
"name": s.get("subject", "") or target,
|
|
52
|
+
"description": s.get("why") or "",
|
|
53
|
+
"properties": props,
|
|
54
|
+
})
|
|
55
|
+
bom = {
|
|
56
|
+
"bomFormat": "CycloneDX", "specVersion": "1.5",
|
|
57
|
+
"serialNumber": serial, "version": 1,
|
|
58
|
+
"metadata": {
|
|
59
|
+
"timestamp": latest_ts,
|
|
60
|
+
# 1.5 非废弃 tools 形态:{components:[...]};legacy tool[] 不允许 description
|
|
61
|
+
# 字段(additionalProperties:false),用 component 形态既携带 description 又过官方 schema
|
|
62
|
+
"tools": {"components": [
|
|
63
|
+
{"type": "application", "name": "codetalk",
|
|
64
|
+
"description": "zero-LLM commit decision provenance"}]},
|
|
65
|
+
"component": {"type": "application", "name": target},
|
|
66
|
+
},
|
|
67
|
+
"components": components,
|
|
68
|
+
}
|
|
69
|
+
# 脱敏在 json.dumps 之前(对原始字符串叶子,见 redact_data 注释):dumps 转义引号会
|
|
70
|
+
# 让 key="value" 形式 secret 漏过 redact_secrets;故 redact 结构而非序列化后文本
|
|
71
|
+
return json.dumps(redact_data(bom), ensure_ascii=False, indent=2)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_adr(target, segments, fmt="madr"):
|
|
75
|
+
"""segments(旧→新,blame.collect_segments 输出)→ 一份 markdown ADR(或 JSON BOM)。零 LLM、落地前脱敏。"""
|
|
76
|
+
if fmt == "cyclonedx":
|
|
77
|
+
return _to_cyclonedx(target, segments)
|
|
78
|
+
whys = [s["why"] for s in segments if s.get("why")]
|
|
79
|
+
decisions = [d for s in segments for d in (s.get("decisions") or [])]
|
|
80
|
+
rejected = [r for s in segments for r in (s.get("rejected") or [])]
|
|
81
|
+
risks = [r for s in segments for r in (s.get("risks") or [])]
|
|
82
|
+
title = (segments[-1].get("subject") if segments else "") or target
|
|
83
|
+
context = whys or ["(无叙事;先跑 codetalk digest / enrich 富集)"]
|
|
84
|
+
# 被否决备选 = ADR 的「Considered Options」本源;仅在有否决记录时出该段(不撑空节)
|
|
85
|
+
considered = (["", "## Considered Options(被否决的备选)", *_bullets(rejected)]
|
|
86
|
+
if rejected else [])
|
|
87
|
+
if fmt == "nygard":
|
|
88
|
+
body = ["# " + title, "", "## Status", "accepted", "",
|
|
89
|
+
"## Context", *context, "",
|
|
90
|
+
"## Decision", *_bullets(decisions), *considered, "",
|
|
91
|
+
"## Consequences", *_bullets(risks)]
|
|
92
|
+
else: # MADR(默认)
|
|
93
|
+
body = ["# " + title, "", "- Status: accepted", "",
|
|
94
|
+
"## Context and Problem Statement", *context, "",
|
|
95
|
+
"## Decision Outcome", *_bullets(decisions), *considered, "",
|
|
96
|
+
"## Consequences", *_bullets(risks)]
|
|
97
|
+
body += ["", "## 来源(真实 commit,逐字可核验)", f"_目标:{target}_"]
|
|
98
|
+
for s in segments: # 每个 commit 的 SHA + 逐字决策/否决原话锚点
|
|
99
|
+
body.append(f"- [{s['sha'][:7]}] {(s.get('date') or '')[:10]} {s.get('subject', '')}")
|
|
100
|
+
for d in (s.get("decisions") or []):
|
|
101
|
+
body.append(f" · {d}")
|
|
102
|
+
for r in (s.get("rejected") or []):
|
|
103
|
+
body.append(f" · (否决){r}")
|
|
104
|
+
return redact_secrets("\n".join(body))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def export(project, target, fmt="madr", vault=None):
|
|
108
|
+
"""CLI 入口:collect_segments → to_adr → 返回 (输出/写盘提示, error)。零 LLM、不触网。"""
|
|
109
|
+
from .blame import _parse_target, collect_segments
|
|
110
|
+
from .cache import Cache
|
|
111
|
+
from .config import CACHE_DB_PATH, load_config
|
|
112
|
+
pp = Path(project).resolve()
|
|
113
|
+
file, start, end = _parse_target(target)
|
|
114
|
+
cache = Cache(CACHE_DB_PATH)
|
|
115
|
+
try:
|
|
116
|
+
segs = collect_segments(cache, pp, file, start, end)
|
|
117
|
+
finally:
|
|
118
|
+
cache.close()
|
|
119
|
+
if not segs:
|
|
120
|
+
return None, f"{file} 没有可用的提交历史,无从导出 ADR。"
|
|
121
|
+
out = to_adr(target, segs, fmt=fmt)
|
|
122
|
+
if vault:
|
|
123
|
+
from . import report
|
|
124
|
+
path = report.write_report(load_config()["vault_path"], pp.name,
|
|
125
|
+
"adr-" + file.replace("/", "_"), out)
|
|
126
|
+
return f"ADR 已写入:{path}", None
|
|
127
|
+
return out, None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def adr_export_cmd(args):
|
|
131
|
+
"""零 LLM:某段代码真实决策史 → MADR/Nygard markdown,逐字引真实 commit。"""
|
|
132
|
+
out, err = export(args.project, args.target, fmt=args.format, vault=args.vault)
|
|
133
|
+
if err:
|
|
134
|
+
print(f"错误:{err}", file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
print(out)
|
|
137
|
+
return 0
|
codetalk/agent_seed.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- codetalk-agent-seed -->
|
|
2
|
+
## codetalk 决策捕获(AI coding agent 必读)
|
|
3
|
+
|
|
4
|
+
每次 `git commit`,在 commit message 正文按下面格式留下这次改动的**关键决策与推导**,
|
|
5
|
+
供 codetalk 作为决策记录长期分析(ask / graph / digest 据此接地,免得日后从 diff 反推)。保持精炼:
|
|
6
|
+
每条一行、总共几行即可,别写长文。**行首精确、区分大小写**。
|
|
7
|
+
|
|
8
|
+
- `Vibe-Decision: <做了什么决定;为什么这么选>` —— 一个关键决策一行,可多条
|
|
9
|
+
- `Vibe-Rejected: <认真考虑过但放弃的备选 + 为何弃>` —— 被否决方案一条一行,可多条;
|
|
10
|
+
这是 diff 结构性取不到的 **why-NOT**,`blame` 会在你日后改到该处时独立标出「否决备选(曾放弃)」,
|
|
11
|
+
防你/AI **重引入**已否决方案(codetalk 护城河:对抗 AI 反推式编造被否决备选)
|
|
12
|
+
- `Vibe-Watch: <没把握、需日后验证的点>` —— 风险 / 待验证,可多条
|
|
13
|
+
|
|
14
|
+
例:
|
|
15
|
+
|
|
16
|
+
Vibe-Decision: 用 --no-merges 跳合并 —— 叙事只要功能提交
|
|
17
|
+
Vibe-Rejected: --first-parent —— 保留合并气泡、丢功能提交,叙事更差
|
|
18
|
+
Vibe-Watch: 空提交尾部 NUL subject 未处理,罕见暂不管
|
codetalk/align.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Soft session-commit alignment: time window + file-path overlap."""
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
TIME_SLACK = timedelta(minutes=30)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _relative_files(session, project_root):
|
|
9
|
+
"""Session file paths are absolute; map them into the repo, drop the rest."""
|
|
10
|
+
rel = set()
|
|
11
|
+
for path in session["files_written"]:
|
|
12
|
+
try:
|
|
13
|
+
rel.add(str(Path(path).relative_to(project_root)))
|
|
14
|
+
except ValueError:
|
|
15
|
+
continue
|
|
16
|
+
return rel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def align(commits, sessions, project_path):
|
|
20
|
+
"""Attach matches=[{session, overlap, confidence}] to every commit.
|
|
21
|
+
|
|
22
|
+
high = commit falls in the session's time window AND touches shared
|
|
23
|
+
files; low = only one signal. Target is soft 80% accuracy by design.
|
|
24
|
+
"""
|
|
25
|
+
project_root = Path(project_path).resolve()
|
|
26
|
+
session_files = [(s, _relative_files(s, project_root)) for s in sessions]
|
|
27
|
+
for commit in commits:
|
|
28
|
+
matches = []
|
|
29
|
+
for session, files in session_files:
|
|
30
|
+
in_window = bool(
|
|
31
|
+
session["start"] and session["end"]
|
|
32
|
+
and session["start"] - TIME_SLACK <= commit["date"]
|
|
33
|
+
<= session["end"] + TIME_SLACK)
|
|
34
|
+
overlap = sorted(files & set(commit["files"]))
|
|
35
|
+
if not in_window and not overlap:
|
|
36
|
+
continue
|
|
37
|
+
confidence = "high" if (in_window and overlap) else "low"
|
|
38
|
+
matches.append({"session": session, "overlap": overlap,
|
|
39
|
+
"confidence": confidence})
|
|
40
|
+
matches.sort(key=lambda m: (m["confidence"] != "high", -len(m["overlap"])))
|
|
41
|
+
commit["matches"] = matches
|
|
42
|
+
return commits
|
codetalk/ask.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""单代码 AI 提问:接项目记忆对一段代码作接地回答。
|
|
2
|
+
|
|
3
|
+
write-time 捕获(commit trailer 面包屑)+ read-time 廉价检索(git log -L → 已缓存
|
|
4
|
+
叙事 + 面包屑 → 一次轻 LLM)。无 key/失败时降级为打印该代码的原始决策史,绝不崩。
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import grounding_render as gr
|
|
12
|
+
from .cache import Cache
|
|
13
|
+
from .config import CACHE_DB_PATH, load_config, redact_data, redact_secrets
|
|
14
|
+
from .gitlog import (line_log, file_log, merge_breadcrumbs, parse_target,
|
|
15
|
+
commit_meta)
|
|
16
|
+
from .llm import LLMClient, LLMError
|
|
17
|
+
from .prompts import ASK_SCHEMA, ASK_SYSTEM_PROMPT
|
|
18
|
+
|
|
19
|
+
EXCERPT = 200
|
|
20
|
+
CONTEXT_BUDGET = 6000
|
|
21
|
+
_parse_target = parse_target # 与 blame 同口径,搬到 gitlog 共享
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _since_args(since):
|
|
25
|
+
"""把 --since 的值分类成 git log 的范围 token(确定性检索,不引向量库):
|
|
26
|
+
含 '..' → 当 commit 范围(如 abc..def)直接作 rev arg;否则当日期 → --since=<值>。
|
|
27
|
+
None/空 → 无范围([]),退化为全历史检索。"""
|
|
28
|
+
since = (since or "").strip()
|
|
29
|
+
if not since:
|
|
30
|
+
return []
|
|
31
|
+
if ".." in since:
|
|
32
|
+
return [since]
|
|
33
|
+
return [f"--since={since}"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _retrieve(project_path, file, start, end, cache, since=None):
|
|
37
|
+
"""→ (context_str, shas oldest-first, code_state, evidence, test_refs, pr_refs)。
|
|
38
|
+
无历史时 context_str 为 ''。code_state = 命中行最新 commit SHA,进缓存键 →
|
|
39
|
+
代码一变旧答案自然失效。evidence = 命中 SHA narrative 的原话锚点汇总;
|
|
40
|
+
pr_refs = 命中 narrative 的 PR 讨论汇总(按 number 去重);旧缓存无键 .get 兼容。
|
|
41
|
+
since:把检索从空间(文件:行)再叠一层时间范围(日期/commit 范围),确定性过滤。"""
|
|
42
|
+
extra = _since_args(since)
|
|
43
|
+
if start is not None:
|
|
44
|
+
shas, err = line_log(project_path, file, start, end, extra=extra)
|
|
45
|
+
if err: # 行级失败 → 文件级降级
|
|
46
|
+
shas, _ = file_log(project_path, file, extra=extra)
|
|
47
|
+
else:
|
|
48
|
+
shas, _ = file_log(project_path, file, extra=extra)
|
|
49
|
+
blocks, evidence, test_refs, pr_refs = [], [], [], []
|
|
50
|
+
_seen_ev, _seen_test, _seen_pr = set(), set(), set()
|
|
51
|
+
for sha in shas:
|
|
52
|
+
narrative = cache.get_narrative(sha) or {}
|
|
53
|
+
decs, risks, rejected = merge_breadcrumbs(narrative, project_path, sha)
|
|
54
|
+
for ev in narrative.get("evidence") or []: # 原话锚点,按 (session_id, ts) 去重
|
|
55
|
+
sid = ev.get("session_id")
|
|
56
|
+
key_ev = (sid, ev.get("ts")) if sid else str(ev)
|
|
57
|
+
if key_ev not in _seen_ev:
|
|
58
|
+
_seen_ev.add(key_ev)
|
|
59
|
+
evidence.append(ev)
|
|
60
|
+
for tr in narrative.get("test_refs") or []: # 本地测试接地源,按 path 去重
|
|
61
|
+
if tr.get("path") not in _seen_test:
|
|
62
|
+
_seen_test.add(tr.get("path"))
|
|
63
|
+
test_refs.append(tr)
|
|
64
|
+
for pr in narrative.get("pr_refs") or []: # PR 讨论接地源,按 number 去重
|
|
65
|
+
if pr.get("number") not in _seen_pr:
|
|
66
|
+
_seen_pr.add(pr.get("number"))
|
|
67
|
+
pr_refs.append(pr)
|
|
68
|
+
# subject 兜底:无叙事无面包屑时也不只剩光秃 [sha](对齐 blame,冷启动可读)
|
|
69
|
+
_date, subject = commit_meta(project_path, sha)
|
|
70
|
+
parts = [f"[{sha[:7]}]" + (f" {subject}" if subject else "")]
|
|
71
|
+
if narrative.get("why"):
|
|
72
|
+
parts.append("意图:" + narrative["why"][:EXCERPT])
|
|
73
|
+
if decs:
|
|
74
|
+
parts.append("决策:" + ";".join(decs)[:EXCERPT])
|
|
75
|
+
if rejected:
|
|
76
|
+
parts.append("否决备选:" + ";".join(rejected)[:EXCERPT])
|
|
77
|
+
if risks:
|
|
78
|
+
parts.append("风险/待验证:" + ";".join(risks)[:EXCERPT])
|
|
79
|
+
blocks.append(" / ".join(parts))
|
|
80
|
+
# 单点脱敏在原始拼接文本上(非 JSON/LLM 编码后):context 由 commit body 面包屑直接拼成,
|
|
81
|
+
# 未经 cache 脱敏。一处脱敏堵三个出口——MCP JSON(_json_text dumps 前)、CLI 降级 stdout、
|
|
82
|
+
# 送外部 LLM 的 prompt(_ask_prompt→narrate,唯一出网例外也须先脱敏)。
|
|
83
|
+
context = redact_secrets("\n".join(blocks)[:CONTEXT_BUDGET])
|
|
84
|
+
code_state = shas[-1] if shas else ""
|
|
85
|
+
return context, shas, code_state, evidence, test_refs, pr_refs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _verify_cited(cited, shas):
|
|
89
|
+
"""只保留能对上检索集合里真实 commit 的引用(双向前缀匹配),丢掉模型自造的 SHA。
|
|
90
|
+
守『引用可点验证』:LLM 自报但不在检索证据里的 SHA 一律不外露、不落缓存。"""
|
|
91
|
+
real = [str(s) for s in (shas or [])]
|
|
92
|
+
return [str(c) for c in cited
|
|
93
|
+
if any(r == str(c) or r.startswith(str(c)) or str(c).startswith(r)
|
|
94
|
+
for r in real)]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ask_prompt(context, question):
|
|
98
|
+
return ("材料(这段代码相关 commit 的叙事与决策面包屑,旧→新):\n"
|
|
99
|
+
f"{context}\n\n问题:{question}\n"
|
|
100
|
+
"只据材料回答;材料不足就说『材料不足』。")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _format(payload):
|
|
104
|
+
cited = "、".join(payload.get("cited_shas") or []) or "(无)"
|
|
105
|
+
out = payload.get("answer", "")
|
|
106
|
+
if payload.get("unsure"):
|
|
107
|
+
out += f"\n\n[不确定] {payload['unsure']}"
|
|
108
|
+
return f"{out}\n\n据此回答的 commit:{cited}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def format_evidence(evidence):
|
|
112
|
+
"""把原话锚点渲染成「原话佐证(可自行核验)」块,供 LLM 答案旁对照(对抗反推式编造)。
|
|
113
|
+
每条列 source·session 短id·ts + 原话/AI 陈述片段;支撑全为 low 时加置信度警示。
|
|
114
|
+
无 evidence → 返回 ''(调用方不追加该块,旧缓存无键经上游 .get 兼容)。脱敏在上游已做。"""
|
|
115
|
+
if not evidence:
|
|
116
|
+
return ""
|
|
117
|
+
lines = [gr.EVIDENCE_TITLE]
|
|
118
|
+
for ev in evidence:
|
|
119
|
+
sid = (ev.get("session_id") or "")[:7]
|
|
120
|
+
head = f"- [{ev.get('source', '?')}·{sid}·{ev.get('ts', '')}" \
|
|
121
|
+
f"·{ev.get('confidence', '?')}]"
|
|
122
|
+
lines.append(head)
|
|
123
|
+
for p in ev.get("prompts") or []:
|
|
124
|
+
lines.append(f" 原话:{p}")
|
|
125
|
+
for e in ev.get("excerpts") or []:
|
|
126
|
+
lines.append(f" AI:{e}")
|
|
127
|
+
if gr.evidence_low_confidence(evidence):
|
|
128
|
+
lines.append(gr.EVIDENCE_LOW_WARN)
|
|
129
|
+
return "\n".join(lines)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_test_refs(test_refs):
|
|
133
|
+
"""「相关测试(从测试场景反推设计)」块:列相关测试文件 + 用例名,供用户反推设计。
|
|
134
|
+
无 → ''(调用方不追加)。对位用户1「看测试用例反推设计」(问卷一 Q3)。"""
|
|
135
|
+
if not test_refs:
|
|
136
|
+
return ""
|
|
137
|
+
lines = [gr.TEST_REFS_TITLE]
|
|
138
|
+
for tr in test_refs:
|
|
139
|
+
names = "、".join(tr.get("names") or []) or "(无显式 test_ 用例)"
|
|
140
|
+
lines.append(f"- {tr.get('path', '')} — {names}")
|
|
141
|
+
return "\n".join(lines)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def format_pr_refs(pr_refs):
|
|
145
|
+
"""「相关 PR 讨论(当初的需求背景)」块:列 #N title — snippet,供反推需求背景。
|
|
146
|
+
无 → ''(调用方不追加)。对位用户1「看 PR 描述找需求背景」(问卷一 Q3,最强 why 源)。"""
|
|
147
|
+
if not pr_refs:
|
|
148
|
+
return ""
|
|
149
|
+
lines = [gr.PR_REFS_TITLE]
|
|
150
|
+
for pr in pr_refs:
|
|
151
|
+
lines.append(f"- #{pr.get('number')} {pr.get('title', '')} — "
|
|
152
|
+
f"{pr.get('snippet', '')}")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _with_evidence(text, evidence, test_refs=(), pr_refs=()):
|
|
157
|
+
for block in (format_evidence(evidence), format_test_refs(test_refs),
|
|
158
|
+
format_pr_refs(pr_refs)):
|
|
159
|
+
if block:
|
|
160
|
+
text = f"{text}\n\n{block}"
|
|
161
|
+
return text
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _json_text(mode, target, question, shas, payload=None, context=None):
|
|
165
|
+
"""组装 agent 可读的结构化结果(与 agent-seed 写时捕获配成读写闭环)。
|
|
166
|
+
llm/cache:带 LLM payload(answer/cited_shas/unsure);degraded:无 LLM,
|
|
167
|
+
给出确定性检索结果(context 原始决策史 + shas),降级也不崩。
|
|
168
|
+
context 已在 _retrieve 脱敏;此处再 redact_data 兜底(结构叶子,dumps 前)防遗漏出口。"""
|
|
169
|
+
obj = {"mode": mode, "target": target, "question": question,
|
|
170
|
+
"shas": list(shas or [])}
|
|
171
|
+
if payload is not None:
|
|
172
|
+
obj["answer"] = payload.get("answer", "")
|
|
173
|
+
obj["cited_shas"] = list(payload.get("cited_shas") or [])
|
|
174
|
+
obj["unsure"] = payload.get("unsure", "")
|
|
175
|
+
if context is not None:
|
|
176
|
+
obj["context"] = context
|
|
177
|
+
return json.dumps(redact_data(obj), ensure_ascii=False, indent=2)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write_note(vault_path, project, target, question, payload):
|
|
181
|
+
vault = Path(vault_path).expanduser()
|
|
182
|
+
vault.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
slug = hashlib.sha256(f"{target}|{question}".encode()).hexdigest()[:8]
|
|
184
|
+
note = f"# 提问:{target}\n\n> {question}\n\n{_format(payload)}\n"
|
|
185
|
+
(vault / f"{project}-ask-{slug}.md").write_text(
|
|
186
|
+
redact_secrets(note), encoding="utf-8")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def answer_question(cache, llm, project_path, project, target, question,
|
|
190
|
+
vault_path=None, since=None, as_json=False):
|
|
191
|
+
"""核心:解析→检索→(命中缓存/无 key 降级/调 LLM)→脱敏缓存→(可选)写笔记。
|
|
192
|
+
返回 (text, error_or_None)。llm=None 表示无 key,降级打印原始决策史。
|
|
193
|
+
since:把检索叠一层时间范围;as_json:text 改为 agent 可读的结构化 JSON。"""
|
|
194
|
+
question = redact_secrets(question) # 用户原始输入:在所有出口(json/note/prompt)前单点脱敏
|
|
195
|
+
file, start, end = _parse_target(target)
|
|
196
|
+
context, shas, code_state, evidence, test_refs, pr_refs = _retrieve(
|
|
197
|
+
project_path, file, start, end, cache, since=since)
|
|
198
|
+
if not context:
|
|
199
|
+
return None, f"{file} 没有可用的提交历史,无从回答。"
|
|
200
|
+
key = "ask:" + hashlib.sha256(
|
|
201
|
+
f"{file}|{start}-{end}|{question}|{code_state}|{since or ''}".encode()
|
|
202
|
+
).hexdigest()[:40]
|
|
203
|
+
cached = cache.get_narrative(key)
|
|
204
|
+
if cached:
|
|
205
|
+
_log_usage(project_path, "cache", llm)
|
|
206
|
+
if as_json:
|
|
207
|
+
return _json_text("cache", target, question, shas,
|
|
208
|
+
payload=cached), None
|
|
209
|
+
return _with_evidence(_format(cached), evidence, test_refs, pr_refs), None
|
|
210
|
+
if llm is None: # 无 API key:降级到原始决策史
|
|
211
|
+
_log_usage(project_path, "degraded", llm)
|
|
212
|
+
if as_json:
|
|
213
|
+
return _json_text("degraded", target, question, shas,
|
|
214
|
+
context=context), None
|
|
215
|
+
return _with_evidence(
|
|
216
|
+
"(未配置 LLM,以下为这段代码的原始决策史)\n" + context,
|
|
217
|
+
evidence, test_refs, pr_refs), None
|
|
218
|
+
try:
|
|
219
|
+
raw = llm.narrate(_ask_prompt(context, question),
|
|
220
|
+
schema=ASK_SCHEMA, system=ASK_SYSTEM_PROMPT)
|
|
221
|
+
except LLMError:
|
|
222
|
+
_log_usage(project_path, "degraded", llm)
|
|
223
|
+
if as_json:
|
|
224
|
+
return _json_text("degraded", target, question, shas,
|
|
225
|
+
context=context), None
|
|
226
|
+
return _with_evidence(
|
|
227
|
+
"(LLM 调用失败,以下为原始决策史)\n" + context,
|
|
228
|
+
evidence, test_refs, pr_refs), None
|
|
229
|
+
payload = {
|
|
230
|
+
"answer": redact_secrets(str(raw.get("answer", ""))),
|
|
231
|
+
"cited_shas": _verify_cited(raw.get("cited_shas") or [], shas),
|
|
232
|
+
"unsure": redact_secrets(str(raw.get("unsure", ""))),
|
|
233
|
+
}
|
|
234
|
+
cache.put_narrative(key, project, llm.model, payload)
|
|
235
|
+
_log_usage(project_path, "llm", llm)
|
|
236
|
+
if vault_path:
|
|
237
|
+
_write_note(vault_path, project, target, question, payload)
|
|
238
|
+
if as_json:
|
|
239
|
+
return _json_text("llm", target, question, shas, payload=payload), None
|
|
240
|
+
return _with_evidence(_format(payload), evidence, test_refs, pr_refs), None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _log_usage(project_path, mode, llm):
|
|
244
|
+
"""记一行 ask 用量(mode=cache/degraded/llm;带 LLM token 省额)。写失败不影响主流程。"""
|
|
245
|
+
from . import report
|
|
246
|
+
stats = getattr(llm, "stats", {}) if llm else {}
|
|
247
|
+
report.append_usage({
|
|
248
|
+
"command": "ask", "project": str(project_path), "mode": mode,
|
|
249
|
+
"llm_calls": stats.get("calls", 0),
|
|
250
|
+
"tokens_in": stats.get("input_tokens", 0),
|
|
251
|
+
"tokens_out": stats.get("output_tokens", 0),
|
|
252
|
+
"cache_hit_tokens": stats.get("cache_hit_tokens", 0),
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def ask(project_path, target, question, vault=None, since=None, as_json=False,
|
|
257
|
+
no_llm=False):
|
|
258
|
+
"""CLI 入口:装配 cache/llm,转 answer_question,打印,返回退出码。"""
|
|
259
|
+
cfg = load_config()
|
|
260
|
+
if vault:
|
|
261
|
+
cfg["vault_path"] = vault
|
|
262
|
+
if no_llm:
|
|
263
|
+
cfg["no_llm"] = True # 硬关 LLM → 下方 LLMClient 抛 LLMError → 降级确定性
|
|
264
|
+
pp = Path(project_path).resolve()
|
|
265
|
+
cache = Cache(CACHE_DB_PATH)
|
|
266
|
+
try:
|
|
267
|
+
llm = LLMClient(cfg)
|
|
268
|
+
except LLMError:
|
|
269
|
+
llm = None # 无 key → 降级,不报错退出
|
|
270
|
+
text, err = answer_question(cache, llm, pp, pp.name, target, question,
|
|
271
|
+
cfg["vault_path"] if vault else None,
|
|
272
|
+
since=since, as_json=as_json)
|
|
273
|
+
cache.close()
|
|
274
|
+
if err:
|
|
275
|
+
print(f"错误:{err}", file=sys.stderr)
|
|
276
|
+
return 2
|
|
277
|
+
print(text)
|
|
278
|
+
return 0
|
codetalk/blame.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""blame:零-LLM 行级决策溯源 —— ask 的确定性孪生。
|
|
2
|
+
|
|
3
|
+
给定文件(可选行范围),用 git 行历史(line_log,行级失败降级文件级)找出触达这些
|
|
4
|
+
行的 commit,映射到已缓存叙事 + Vibe-Decision 面包屑,确定性打印每段决策史
|
|
5
|
+
(SHA·日期·subject·decisions)。ask 用 LLM 综合,blame 只如实罗列;无 key 也能用。
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import grounding_render as gr
|
|
12
|
+
from .cache import Cache
|
|
13
|
+
from .config import CACHE_DB_PATH, redact_data, redact_secrets
|
|
14
|
+
from .gitlog import (commit_body, commit_meta, file_log, line_log,
|
|
15
|
+
parse_breadcrumbs, parse_rejected, parse_target)
|
|
16
|
+
|
|
17
|
+
_parse_target = parse_target # 与 ask 同口径,搬到 gitlog 共享
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def segment_has_why(seg):
|
|
21
|
+
"""该段是否带 authored why(narrative why / decisions / 否决备选 / evidence 任一)。
|
|
22
|
+
Vibe-Watch(risks)是前瞻预测、非『为什么这么写』,不计(与 grounding_hitrate/recall 同口径)。"""
|
|
23
|
+
return bool((seg.get("why") or "").strip() or seg.get("decisions")
|
|
24
|
+
or seg.get("rejected") or seg.get("evidence"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_shas(project_path, file, start, end):
|
|
28
|
+
"""→ (shas 旧→新, precision)。precision 是**确定性溯源准度**信号、非语义对错:
|
|
29
|
+
line=行级精确命中;file=行级失败或无范围→文件级降级(可能含本块外历史);none=无任何历史。"""
|
|
30
|
+
if start is not None:
|
|
31
|
+
shas, err = line_log(project_path, file, start, end)
|
|
32
|
+
if not err:
|
|
33
|
+
return shas, ("line" if shas else "none")
|
|
34
|
+
shas, _ = file_log(project_path, file) # 行级失败 → 文件级降级
|
|
35
|
+
return shas, ("file" if shas else "none")
|
|
36
|
+
shas, _ = file_log(project_path, file)
|
|
37
|
+
return shas, ("file" if shas else "none")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_segments(cache, project_path, shas):
|
|
41
|
+
"""shas → 每 commit 一段(旧→新),含 sha/date/subject/why/decisions/risks/evidence/refs。
|
|
42
|
+
每段 decisions = 缓存叙事决策 ∪ 面包屑(去重,缓存已折入不重复)。"""
|
|
43
|
+
segments = []
|
|
44
|
+
for sha in shas:
|
|
45
|
+
narrative = cache.get_narrative(sha) or {}
|
|
46
|
+
body = commit_body(project_path, sha)
|
|
47
|
+
authored_decisions, authored_risks = parse_breadcrumbs(body)
|
|
48
|
+
authored_rejected = parse_rejected(body)
|
|
49
|
+
generated_decisions = narrative.get("decisions") or []
|
|
50
|
+
generated_rejected = narrative.get("rejected") or []
|
|
51
|
+
decs = list(dict.fromkeys(generated_decisions + authored_decisions))
|
|
52
|
+
risks = list(dict.fromkeys(
|
|
53
|
+
(narrative.get("risks") or []) + authored_risks))
|
|
54
|
+
rejected = list(dict.fromkeys(generated_rejected + authored_rejected))
|
|
55
|
+
date_iso, subject = commit_meta(project_path, sha)
|
|
56
|
+
segments.append({
|
|
57
|
+
"sha": sha, "date": date_iso, "subject": subject,
|
|
58
|
+
"why": narrative.get("why") or "",
|
|
59
|
+
"decisions": decs, "risks": risks, "rejected": rejected,
|
|
60
|
+
"authored_decisions": authored_decisions,
|
|
61
|
+
"authored_rejected": authored_rejected,
|
|
62
|
+
"generated_decisions": [d for d in generated_decisions
|
|
63
|
+
if d not in authored_decisions],
|
|
64
|
+
"generated_rejected": [r for r in generated_rejected
|
|
65
|
+
if r not in authored_rejected],
|
|
66
|
+
"evidence": narrative.get("evidence") or [], # 旧缓存无键 .get 兼容
|
|
67
|
+
"test_refs": narrative.get("test_refs") or [],
|
|
68
|
+
"pr_refs": narrative.get("pr_refs") or [],
|
|
69
|
+
})
|
|
70
|
+
return segments
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def collect_segments(cache, project_path, file, start, end):
|
|
74
|
+
"""→ 触达这些行的每个 commit 一段(旧→新)。行级失败降级文件级。
|
|
75
|
+
(契约不变:adr_export/review/retrieval/mcp_server/blame 五处依赖此签名与行为;
|
|
76
|
+
需溯源准度信号时用 collect_graded。)"""
|
|
77
|
+
shas, _ = _resolve_shas(project_path, file, start, end)
|
|
78
|
+
return _build_segments(cache, project_path, shas)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def collect_graded(cache, project_path, file, start, end):
|
|
82
|
+
"""同 collect_segments,但额外返回溯源精度 → (segments, precision)。
|
|
83
|
+
供 review 诚实标注每块『这块溯源有多准』(行级精确 / 文件级降级 / 无据),非判 why 对错。"""
|
|
84
|
+
shas, precision = _resolve_shas(project_path, file, start, end)
|
|
85
|
+
return _build_segments(cache, project_path, shas), precision
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _emit_evidence(lines, evidence):
|
|
89
|
+
"""把该段原话锚点确定性追加进输出(零 LLM):source·短id·ts + 原话/AI 片段;
|
|
90
|
+
支撑全为 low 时加置信度警示。无 evidence 不输出块。脱敏已在 enrich 上游做。"""
|
|
91
|
+
if not evidence:
|
|
92
|
+
return
|
|
93
|
+
lines.append(" " + gr.EVIDENCE_TITLE)
|
|
94
|
+
for ev in evidence:
|
|
95
|
+
sid = (ev.get("session_id") or "")[:7]
|
|
96
|
+
lines.append(f" [{ev.get('source', '?')}·{sid}·{ev.get('ts', '')}"
|
|
97
|
+
f"·{ev.get('confidence', '?')}]")
|
|
98
|
+
for p in ev.get("prompts") or []:
|
|
99
|
+
lines.append(f" 原话:{p}")
|
|
100
|
+
for e in ev.get("excerpts") or []:
|
|
101
|
+
lines.append(f" AI:{e}")
|
|
102
|
+
if gr.evidence_low_confidence(evidence):
|
|
103
|
+
lines.append(" " + gr.EVIDENCE_LOW_WARN)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _emit_test_refs(lines, test_refs):
|
|
107
|
+
"""「相关测试(从测试场景反推设计)」确定性追加(零 LLM)。无则不输出。"""
|
|
108
|
+
if not test_refs:
|
|
109
|
+
return
|
|
110
|
+
lines.append(" " + gr.TEST_REFS_TITLE)
|
|
111
|
+
for tr in test_refs:
|
|
112
|
+
names = "、".join(tr.get("names") or []) or "(无显式 test_ 用例)"
|
|
113
|
+
lines.append(f" {tr.get('path', '')} — {names}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _emit_pr_refs(lines, pr_refs):
|
|
117
|
+
"""「相关 PR 讨论(当初的需求背景)」确定性追加(零 LLM)。无则不输出。"""
|
|
118
|
+
if not pr_refs:
|
|
119
|
+
return
|
|
120
|
+
lines.append(" " + gr.PR_REFS_TITLE)
|
|
121
|
+
for pr in pr_refs:
|
|
122
|
+
lines.append(f" #{pr.get('number')} {pr.get('title', '')} — "
|
|
123
|
+
f"{pr.get('snippet', '')}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _format(file, start, end, segments):
|
|
127
|
+
span = f"{file}:{start}-{end}" if start is not None else file
|
|
128
|
+
lines = [f"# blame {span}(旧→新,共 {len(segments)} 个 commit 触达)\n"]
|
|
129
|
+
for seg in segments:
|
|
130
|
+
date = (seg["date"] or "")[:10] # 仅日期部分
|
|
131
|
+
lines.append(f"[{seg['sha'][:7]}] {date} {seg['subject']}")
|
|
132
|
+
if seg["why"]:
|
|
133
|
+
lines.append(f" 意图:{seg['why']}")
|
|
134
|
+
for dec in seg["decisions"]:
|
|
135
|
+
lines.append(f" 决策:{dec}")
|
|
136
|
+
for rej in seg.get("rejected") or []: # 否决备选:diff 取不到的 why-NOT,防重引入
|
|
137
|
+
lines.append(f" 否决备选(曾放弃):{rej}")
|
|
138
|
+
for risk in seg["risks"]:
|
|
139
|
+
lines.append(f" 待验证:{risk}")
|
|
140
|
+
_emit_evidence(lines, seg.get("evidence") or [])
|
|
141
|
+
_emit_test_refs(lines, seg.get("test_refs") or [])
|
|
142
|
+
_emit_pr_refs(lines, seg.get("pr_refs") or [])
|
|
143
|
+
lines.append("")
|
|
144
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def blame(project_path, target, json_output=False):
|
|
148
|
+
"""CLI 入口:解析→收集→确定性打印,返回退出码。零 LLM,无 key 也能用。"""
|
|
149
|
+
file, start, end = _parse_target(target)
|
|
150
|
+
pp = Path(project_path).resolve()
|
|
151
|
+
cache = Cache(CACHE_DB_PATH)
|
|
152
|
+
segments = collect_segments(cache, pp, file, start, end)
|
|
153
|
+
cache.close()
|
|
154
|
+
if not segments:
|
|
155
|
+
print(f"错误:{file} 没有可用的提交历史,无从溯源。", file=sys.stderr)
|
|
156
|
+
return 2
|
|
157
|
+
if json_output:
|
|
158
|
+
# 脱敏在 json.dumps 之前(对原始字符串叶子):dumps 会把 " 转义,
|
|
159
|
+
# 若先 dumps 后 redact,key="value" 形式 secret 会因引号转义漏网(见 redact_data 注释)
|
|
160
|
+
print(json.dumps(redact_data(segments), ensure_ascii=False), end="")
|
|
161
|
+
else:
|
|
162
|
+
print(redact_secrets(_format(file, start, end, segments)), end="")
|
|
163
|
+
# 冷启动 on-ramp:全是裸 subject(无 why/决策/面包屑)时,别让陌生人以为=git log。
|
|
164
|
+
# 末行(stderr,不污染管道)指下一步:富集或装面包屑捕捉。
|
|
165
|
+
if not any(segment_has_why(s) for s in segments):
|
|
166
|
+
print("\n提示:该文件暂无决策记录(仅 commit 标题)。跑 `codetalk enrich .` "
|
|
167
|
+
"查看计划,确认后加 `--allow-remote`;或 `codetalk install-agent-seed .` "
|
|
168
|
+
"让 AI 提交时留 why。",
|
|
169
|
+
file=sys.stderr)
|
|
170
|
+
return 0
|