devin-qa-pack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devin_qa_pack/__init__.py +1 -0
- devin_qa_pack/adapters/__init__.py +17 -0
- devin_qa_pack/adapters/aider.py +91 -0
- devin_qa_pack/adapters/base.py +26 -0
- devin_qa_pack/adapters/claude_code.py +131 -0
- devin_qa_pack/adapters/devin.py +28 -0
- devin_qa_pack/claims.py +226 -0
- devin_qa_pack/cli.py +354 -0
- devin_qa_pack/html_report.py +193 -0
- devin_qa_pack/intent.py +588 -0
- devin_qa_pack/paths.py +46 -0
- devin_qa_pack/report.py +171 -0
- devin_qa_pack/session_end.py +262 -0
- devin_qa_pack/verify.py +446 -0
- devin_qa_pack-0.1.0.dist-info/METADATA +330 -0
- devin_qa_pack-0.1.0.dist-info/RECORD +20 -0
- devin_qa_pack-0.1.0.dist-info/WHEEL +5 -0
- devin_qa_pack-0.1.0.dist-info/entry_points.txt +2 -0
- devin_qa_pack-0.1.0.dist-info/licenses/LICENSE +21 -0
- devin_qa_pack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Source adapters: map an agent's transcript format into the audit model.
|
|
2
|
+
|
|
3
|
+
Every adapter produces a :class:`SourceSession` — the claims an agent
|
|
4
|
+
made plus the tool calls recorded as ground truth, normalized so the
|
|
5
|
+
same verifier runs over Devin stores and foreign transcripts alike.
|
|
6
|
+
|
|
7
|
+
- :mod:`devin_qa_pack.adapters.devin` — the native store
|
|
8
|
+
(``sessions.db`` via devin-internals-spec).
|
|
9
|
+
- :mod:`devin_qa_pack.adapters.aider` — ``.aider.chat.history.md``
|
|
10
|
+
(experimental).
|
|
11
|
+
- :mod:`devin_qa_pack.adapters.claude_code` — Claude Code ``.jsonl``
|
|
12
|
+
session transcripts (experimental).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from devin_qa_pack.adapters.base import SourceSession
|
|
16
|
+
|
|
17
|
+
__all__ = ["SourceSession"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Experimental Aider adapter — audits ``.aider.chat.history.md``.
|
|
2
|
+
|
|
3
|
+
Aider records its chat history as markdown: ``####`` lines are user
|
|
4
|
+
turns, ``>`` lines are commands issued through aider and everything else
|
|
5
|
+
is assistant prose (the claim source).
|
|
6
|
+
|
|
7
|
+
Evidence is deliberately weak where the format is weak: a recorded
|
|
8
|
+
``/run`` or ``/test`` command becomes an execute call with ``unknown``
|
|
9
|
+
status — the history does not persist exit codes — so test claims
|
|
10
|
+
resolve ``unverifiable``, never a fake ``verified``. Commit claims are
|
|
11
|
+
still checkable: aider auto-commits, so the repo's own ``git log`` is
|
|
12
|
+
ground truth whenever the working directory is on disk.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from devin_qa_pack.adapters.base import SourceSession
|
|
20
|
+
from devin_qa_pack.claims import claims_from_pairs
|
|
21
|
+
from devin_qa_pack.verify import ParsedToolCall
|
|
22
|
+
|
|
23
|
+
SOURCE = "aider"
|
|
24
|
+
|
|
25
|
+
_RUN_SLASH = ("/run", "/test")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _shell_command(recorded: str) -> str | None:
|
|
29
|
+
"""Map a ``>`` history line to the shell command it ran, if any.
|
|
30
|
+
|
|
31
|
+
``/run`` and ``/test`` execute a shell command; other slash commands
|
|
32
|
+
(``/add``, ``/model``…) do not. A bare ``>`` line is treated as a
|
|
33
|
+
raw command.
|
|
34
|
+
"""
|
|
35
|
+
for slash in _RUN_SLASH:
|
|
36
|
+
if recorded == slash or recorded.startswith(slash + " "):
|
|
37
|
+
return recorded[len(slash):].strip() or None
|
|
38
|
+
if recorded.startswith("/"):
|
|
39
|
+
return None
|
|
40
|
+
return recorded or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse(text: str) -> tuple[list[tuple[str, str]], list[ParsedToolCall]]:
|
|
44
|
+
pairs: list[tuple[str, str]] = []
|
|
45
|
+
calls: list[ParsedToolCall] = []
|
|
46
|
+
for raw in text.splitlines():
|
|
47
|
+
line = raw.rstrip()
|
|
48
|
+
if not line.strip():
|
|
49
|
+
continue
|
|
50
|
+
if line.startswith("####"):
|
|
51
|
+
pairs.append(("user", line[4:].strip()))
|
|
52
|
+
continue
|
|
53
|
+
if line.startswith("#"):
|
|
54
|
+
continue # "# aider chat started at …" and section headers
|
|
55
|
+
if line.startswith(">"):
|
|
56
|
+
command = _shell_command(line[1:].strip())
|
|
57
|
+
if command is not None:
|
|
58
|
+
n = len(calls) + 1
|
|
59
|
+
calls.append(
|
|
60
|
+
ParsedToolCall(
|
|
61
|
+
tool_call_id=f"aider-cmd-{n}",
|
|
62
|
+
kind="execute",
|
|
63
|
+
status="unknown", # history does not record exit codes
|
|
64
|
+
commands=(command,),
|
|
65
|
+
search_text=command.lower().replace("\\", "/"),
|
|
66
|
+
http_statuses=(),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
pairs.append(("assistant", line))
|
|
71
|
+
return pairs, calls
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load(path: str | Path, working_directory: str | None = None) -> SourceSession:
|
|
75
|
+
"""Load ``.aider.chat.history.md`` into the normalized audit view.
|
|
76
|
+
|
|
77
|
+
``working_directory`` defaults to the directory containing the
|
|
78
|
+
history file — aider writes it at the repository root.
|
|
79
|
+
"""
|
|
80
|
+
p = Path(path).expanduser()
|
|
81
|
+
pairs, calls = _parse(p.read_text(encoding="utf-8", errors="replace"))
|
|
82
|
+
session_id = f"aider:{p.name}"
|
|
83
|
+
return SourceSession(
|
|
84
|
+
source=SOURCE,
|
|
85
|
+
session_id=session_id,
|
|
86
|
+
title=p.name,
|
|
87
|
+
working_directory=working_directory or str(p.parent),
|
|
88
|
+
claims=tuple(claims_from_pairs(pairs, session_id)),
|
|
89
|
+
calls=tuple(calls),
|
|
90
|
+
raw_call_count=len(calls),
|
|
91
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The normalized session view every adapter produces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from devin_qa_pack.claims import Claim
|
|
8
|
+
from devin_qa_pack.verify import ParsedToolCall
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class SourceSession:
|
|
13
|
+
"""One session's claims plus its recorded tool calls.
|
|
14
|
+
|
|
15
|
+
``raw_call_count`` is the number of evidence rows *before* decoding —
|
|
16
|
+
when rows exist but none parse, ground truth is unreadable and claims
|
|
17
|
+
resolve ``unverifiable`` rather than ``disputed``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
source: str # adapter name: "devin" | "aider" | "claude-code" | ...
|
|
21
|
+
session_id: str
|
|
22
|
+
title: str | None
|
|
23
|
+
working_directory: str
|
|
24
|
+
claims: tuple[Claim, ...]
|
|
25
|
+
calls: tuple[ParsedToolCall, ...]
|
|
26
|
+
raw_call_count: int
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Experimental Claude Code adapter — audits session ``.jsonl`` transcripts.
|
|
2
|
+
|
|
3
|
+
Claude Code writes one JSONL transcript per session (usually under
|
|
4
|
+
``~/.claude/projects/<slug>/``). ``assistant`` entries carry ``text``
|
|
5
|
+
blocks — the claim source — and ``tool_use`` blocks; the matching
|
|
6
|
+
``tool_result`` blocks in ``user`` entries mark calls completed/failed
|
|
7
|
+
and carry the output used as evidence text.
|
|
8
|
+
|
|
9
|
+
Tool mapping: ``Bash`` → execute, ``Write``/``Edit``/``MultiEdit``/
|
|
10
|
+
``NotebookEdit`` → write, anything else → other (its payload text still
|
|
11
|
+
contributes evidence). Entries carry a ``cwd`` field used as the working
|
|
12
|
+
directory for git/file checks when no override is given.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Iterable
|
|
20
|
+
|
|
21
|
+
from devin_qa_pack.adapters.base import SourceSession
|
|
22
|
+
from devin_qa_pack.claims import claims_from_pairs
|
|
23
|
+
from devin_qa_pack.verify import ParsedToolCall, _http_statuses, _strings
|
|
24
|
+
|
|
25
|
+
SOURCE = "claude-code"
|
|
26
|
+
|
|
27
|
+
_EXECUTE_TOOLS = {"bash", "shell", "execute", "run", "terminal"}
|
|
28
|
+
_WRITE_TOOLS = {"write", "edit", "multiedit", "notebookedit"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _entries(path: Path) -> Iterable[dict[str, Any]]:
|
|
32
|
+
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
33
|
+
raw = raw.strip()
|
|
34
|
+
if not raw:
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
entry = json.loads(raw)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
continue
|
|
40
|
+
if isinstance(entry, dict):
|
|
41
|
+
yield entry
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _blocks(entry: dict[str, Any]) -> list[dict[str, Any]]:
|
|
45
|
+
msg = entry.get("message")
|
|
46
|
+
content = msg.get("content") if isinstance(msg, dict) else entry.get("content")
|
|
47
|
+
if isinstance(content, str):
|
|
48
|
+
return [{"type": "text", "text": content}]
|
|
49
|
+
if isinstance(content, list):
|
|
50
|
+
return [b for b in content if isinstance(b, dict)]
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _result_text(content: Any) -> str:
|
|
55
|
+
if isinstance(content, str):
|
|
56
|
+
return content
|
|
57
|
+
if isinstance(content, list):
|
|
58
|
+
return "\n".join(
|
|
59
|
+
str(b.get("text", "")) for b in content
|
|
60
|
+
if isinstance(b, dict) and isinstance(b.get("text"), str)
|
|
61
|
+
)
|
|
62
|
+
return "" if content is None else str(content)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tool_kind(name: str) -> str:
|
|
66
|
+
if name in _EXECUTE_TOOLS:
|
|
67
|
+
return "execute"
|
|
68
|
+
if name in _WRITE_TOOLS:
|
|
69
|
+
return "write"
|
|
70
|
+
return "other"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load(path: str | Path, working_directory: str | None = None) -> SourceSession:
|
|
74
|
+
p = Path(path).expanduser()
|
|
75
|
+
pairs: list[tuple[str, str]] = []
|
|
76
|
+
uses: dict[str, dict[str, Any]] = {}
|
|
77
|
+
order: list[str] = []
|
|
78
|
+
status_by_id: dict[str, str] = {}
|
|
79
|
+
out_by_id: dict[str, str] = {}
|
|
80
|
+
cwd = working_directory
|
|
81
|
+
|
|
82
|
+
for entry in _entries(p):
|
|
83
|
+
role = entry.get("type") or ""
|
|
84
|
+
if cwd is None and isinstance(entry.get("cwd"), str):
|
|
85
|
+
cwd = entry["cwd"]
|
|
86
|
+
for b in _blocks(entry):
|
|
87
|
+
btype = b.get("type")
|
|
88
|
+
if btype == "text":
|
|
89
|
+
pairs.append((str(role or "assistant"), str(b.get("text", ""))))
|
|
90
|
+
elif btype == "tool_use":
|
|
91
|
+
tid = str(b.get("id") or f"call-{len(order) + 1}")
|
|
92
|
+
if tid not in uses:
|
|
93
|
+
order.append(tid)
|
|
94
|
+
uses[tid] = b
|
|
95
|
+
elif btype == "tool_result":
|
|
96
|
+
tid = str(b.get("tool_use_id") or "")
|
|
97
|
+
out_by_id[tid] = _result_text(b.get("content"))
|
|
98
|
+
status_by_id[tid] = "failed" if b.get("is_error") else "completed"
|
|
99
|
+
|
|
100
|
+
calls: list[ParsedToolCall] = []
|
|
101
|
+
for tid in order:
|
|
102
|
+
use = uses[tid]
|
|
103
|
+
name = str(use.get("name") or "").lower()
|
|
104
|
+
inp = use.get("input") if isinstance(use.get("input"), dict) else {}
|
|
105
|
+
command = inp.get("command")
|
|
106
|
+
commands = (command,) if isinstance(command, str) and command.strip() else ()
|
|
107
|
+
output = out_by_id.get(tid, "")
|
|
108
|
+
texts = [*_strings(inp), output, name]
|
|
109
|
+
calls.append(
|
|
110
|
+
ParsedToolCall(
|
|
111
|
+
tool_call_id=tid,
|
|
112
|
+
kind=_tool_kind(name),
|
|
113
|
+
status=status_by_id.get(tid, "unknown"),
|
|
114
|
+
commands=commands,
|
|
115
|
+
search_text=" ".join(texts).lower().replace("\\", "/"),
|
|
116
|
+
http_statuses=tuple(sorted(
|
|
117
|
+
{*_http_statuses(inp), *_http_statuses(output)}
|
|
118
|
+
)),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
session_id = f"claude-code:{p.name}"
|
|
123
|
+
return SourceSession(
|
|
124
|
+
source=SOURCE,
|
|
125
|
+
session_id=session_id,
|
|
126
|
+
title=p.name,
|
|
127
|
+
working_directory=cwd or str(p.parent),
|
|
128
|
+
claims=tuple(claims_from_pairs(pairs, session_id)),
|
|
129
|
+
calls=tuple(calls),
|
|
130
|
+
raw_call_count=len(calls),
|
|
131
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The Devin adapter — the native ``sessions.db`` source.
|
|
2
|
+
|
|
3
|
+
Claims come from ``message_nodes`` (agent-role text); ground truth comes
|
|
4
|
+
from ``tool_call_state`` rows decoded via devin-internals-spec parsers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from devin_internals.parsers import SessionsStore
|
|
10
|
+
from devin_internals.parsers.sessions import Session
|
|
11
|
+
|
|
12
|
+
from devin_qa_pack.adapters.base import SourceSession
|
|
13
|
+
from devin_qa_pack.claims import extract_claims
|
|
14
|
+
from devin_qa_pack.verify import parse_tool_calls
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def session_view(store: SessionsStore, session: Session) -> SourceSession:
|
|
18
|
+
nodes = store.message_nodes(session.id)
|
|
19
|
+
states = store.tool_call_state(session.id)
|
|
20
|
+
return SourceSession(
|
|
21
|
+
source="devin",
|
|
22
|
+
session_id=session.id,
|
|
23
|
+
title=session.title,
|
|
24
|
+
working_directory=session.working_directory,
|
|
25
|
+
claims=tuple(extract_claims(nodes)),
|
|
26
|
+
calls=tuple(parse_tool_calls(states)),
|
|
27
|
+
raw_call_count=len(states),
|
|
28
|
+
)
|
devin_qa_pack/claims.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Extract deliverable claims from a session's ``message_nodes``.
|
|
2
|
+
|
|
3
|
+
A *claim* is an assertion the agent made about delivered work: tests
|
|
4
|
+
passed, a commit exists, a file was created/changed, work was pushed.
|
|
5
|
+
Only agent-role messages are scanned — a user prompt quoting the same
|
|
6
|
+
words is not a delivery claim.
|
|
7
|
+
|
|
8
|
+
``chat_message`` payloads are marked *unstable* in devin-internals-spec
|
|
9
|
+
SCHEMA.md, so decoding is defensive: JSON dicts are checked for the
|
|
10
|
+
usual role/text/content keys (including content-block lists); anything
|
|
11
|
+
that is not JSON at all is scanned as plain text.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Iterable
|
|
20
|
+
|
|
21
|
+
from devin_internals.parsers.sessions import MessageNode
|
|
22
|
+
|
|
23
|
+
TESTS = "tests"
|
|
24
|
+
COMMIT = "commit"
|
|
25
|
+
FILE = "file"
|
|
26
|
+
PUSH = "push"
|
|
27
|
+
HTTP = "http"
|
|
28
|
+
URL = "url"
|
|
29
|
+
|
|
30
|
+
CLAIM_KINDS = (TESTS, COMMIT, FILE, PUSH, HTTP, URL)
|
|
31
|
+
|
|
32
|
+
_RUNNER_TOKENS = (
|
|
33
|
+
"cargo test",
|
|
34
|
+
"go test",
|
|
35
|
+
"npm test",
|
|
36
|
+
"pytest",
|
|
37
|
+
"unittest",
|
|
38
|
+
"vitest",
|
|
39
|
+
"jest",
|
|
40
|
+
"mocha",
|
|
41
|
+
"tox",
|
|
42
|
+
"nose",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_TESTISH = r"(?:tests?|test suite|pytest|unittest|jest|vitest|mocha|tox|nose)"
|
|
46
|
+
_TESTS_RE = re.compile(
|
|
47
|
+
rf"\b{_TESTISH}\b[^.\n]*?\b(?:pass(?:ed|es|ing)?|green|succeed(?:ed|ing)?|ok)\b"
|
|
48
|
+
rf"|\b\d+\s+(?:{_TESTISH}\s+)?passed\b",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
_COMMIT_WORD_RE = re.compile(r"\bcommit(?:s|ted|ting)?\b", re.IGNORECASE)
|
|
52
|
+
_HEX_RE = re.compile(r"\b[0-9a-f]{7,40}\b")
|
|
53
|
+
_PUSH_RE = re.compile(
|
|
54
|
+
r"\bgit\s+push\b"
|
|
55
|
+
r"|\bpush(?:ed|ing)?\b[^.\n]*\b(?:origin|upstream|remote|main|master|branch)\b",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
_FILE_VERB_RE = re.compile(
|
|
59
|
+
r"\b(?:created?|wrote|written|writing|added|adding|updated?|updating|"
|
|
60
|
+
r"modified|changed|edited|generated|saved|deleted|removed|renamed|moved)\b",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
_PATH_TOKEN_RE = re.compile(
|
|
64
|
+
r"(?<![\w/.-])(?:[A-Za-z]:[\\/])?[A-Za-z0-9_-]+(?:[\\/][\w.@-]+)*"
|
|
65
|
+
r"\.[A-Za-z0-9]{2,10}\b"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# "the API returned 200" / "endpoint responded with 404" / "HTTP 500" /
|
|
69
|
+
# "status code: 200" — the captured group is the claimed status code.
|
|
70
|
+
_URL_RE = re.compile(r"https://[\w.-]+(?::\d+)?(?:/[\w./?%&=~#+-]*)?")
|
|
71
|
+
|
|
72
|
+
_HTTP_CLAIM_RES = (
|
|
73
|
+
re.compile(
|
|
74
|
+
r"\b(?:returned|responded|replied|answered|came\s+back)\b"
|
|
75
|
+
r"[^.\n]*?\b([1-5]\d{2})\b",
|
|
76
|
+
re.IGNORECASE,
|
|
77
|
+
),
|
|
78
|
+
re.compile(r"\bHTTP\s+(?:status\s*(?:code)?\s*)?([1-5]\d{2})\b",
|
|
79
|
+
re.IGNORECASE),
|
|
80
|
+
re.compile(
|
|
81
|
+
r"\bstatus\s*(?:code)?\s*(?:of|was|is|:|=)\s*([1-5]\d{2})\b",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
_SKIP_ROLES = {"user", "human"}
|
|
87
|
+
_MAX_EXCERPT = 160
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Claim:
|
|
92
|
+
"""One deliverable claim found in an agent message.
|
|
93
|
+
|
|
94
|
+
``detail`` carries the checkable part: the test-runner name (or
|
|
95
|
+
``"tests"`` when generic), the commit hash, the claimed file path, or
|
|
96
|
+
``"push"``. ``excerpt`` is the source line, truncated — evidence, not
|
|
97
|
+
full row content.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
kind: str
|
|
101
|
+
detail: str
|
|
102
|
+
session_id: str
|
|
103
|
+
node_id: int
|
|
104
|
+
excerpt: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _decode_message(chat_message: str) -> tuple[str | None, str]:
|
|
108
|
+
"""Return ``(role, text)``. Non-JSON payloads become ``(None, raw)``."""
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(chat_message)
|
|
111
|
+
except (json.JSONDecodeError, TypeError):
|
|
112
|
+
return None, chat_message
|
|
113
|
+
if isinstance(data, str):
|
|
114
|
+
return None, data
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
return None, ""
|
|
117
|
+
role = data.get("role") or data.get("author") or data.get("sender")
|
|
118
|
+
return (str(role).lower() if role is not None else None), _text_of(data)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _text_of(data: dict[str, Any]) -> str:
|
|
122
|
+
for key in ("text", "content", "message", "body", "markdown"):
|
|
123
|
+
value = data.get(key)
|
|
124
|
+
if isinstance(value, str):
|
|
125
|
+
return value
|
|
126
|
+
if isinstance(value, list):
|
|
127
|
+
parts = [
|
|
128
|
+
block.get("text", "")
|
|
129
|
+
for block in value
|
|
130
|
+
if isinstance(block, dict)
|
|
131
|
+
]
|
|
132
|
+
text = "\n".join(p for p in parts if p)
|
|
133
|
+
if text:
|
|
134
|
+
return text
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _excerpt(line: str) -> str:
|
|
139
|
+
line = " ".join(line.split())
|
|
140
|
+
return line if len(line) <= _MAX_EXCERPT else line[: _MAX_EXCERPT - 1] + "…"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _normalize_path(token: str) -> str:
|
|
144
|
+
return token.replace("\\", "/").rstrip(".,;:!?)]}>")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _runner_in(line: str) -> str | None:
|
|
148
|
+
low = line.lower()
|
|
149
|
+
for token in _RUNNER_TOKENS: # multi-word runners first
|
|
150
|
+
if re.search(rf"\b{re.escape(token)}\b", low):
|
|
151
|
+
return token
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _claims_in_line(line: str) -> Iterable[tuple[str, str]]:
|
|
156
|
+
if _TESTS_RE.search(line):
|
|
157
|
+
yield TESTS, _runner_in(line) or TESTS
|
|
158
|
+
if _COMMIT_WORD_RE.search(line):
|
|
159
|
+
for m in _HEX_RE.finditer(line):
|
|
160
|
+
yield COMMIT, m.group(0).lower()
|
|
161
|
+
if _PUSH_RE.search(line):
|
|
162
|
+
yield PUSH, PUSH
|
|
163
|
+
if _FILE_VERB_RE.search(line):
|
|
164
|
+
for m in _PATH_TOKEN_RE.finditer(line):
|
|
165
|
+
yield FILE, _normalize_path(m.group(0))
|
|
166
|
+
for rx in _HTTP_CLAIM_RES:
|
|
167
|
+
for m in rx.finditer(line):
|
|
168
|
+
yield HTTP, m.group(1)
|
|
169
|
+
if re.search(
|
|
170
|
+
r"\b(?:deployed|live|hosted|available|published|running)\b", line, re.I
|
|
171
|
+
):
|
|
172
|
+
for m in _URL_RE.finditer(line):
|
|
173
|
+
yield URL, m.group(0).rstrip(".,);'\"")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _collect(
|
|
177
|
+
session_id: str,
|
|
178
|
+
node_id: int,
|
|
179
|
+
text: str,
|
|
180
|
+
claims: list[Claim],
|
|
181
|
+
seen: set[tuple[str, str]],
|
|
182
|
+
) -> None:
|
|
183
|
+
for line in text.splitlines():
|
|
184
|
+
for kind, detail in _claims_in_line(line):
|
|
185
|
+
if not detail or (kind, detail) in seen:
|
|
186
|
+
continue
|
|
187
|
+
seen.add((kind, detail))
|
|
188
|
+
claims.append(
|
|
189
|
+
Claim(
|
|
190
|
+
kind=kind,
|
|
191
|
+
detail=detail,
|
|
192
|
+
session_id=session_id,
|
|
193
|
+
node_id=node_id,
|
|
194
|
+
excerpt=_excerpt(line),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def extract_claims(nodes: Iterable[MessageNode]) -> list[Claim]:
|
|
200
|
+
"""All claims across ``nodes``, deduplicated by ``(kind, detail)``."""
|
|
201
|
+
claims: list[Claim] = []
|
|
202
|
+
seen: set[tuple[str, str]] = set()
|
|
203
|
+
for node in nodes:
|
|
204
|
+
role, text = _decode_message(node.chat_message)
|
|
205
|
+
if role in _SKIP_ROLES:
|
|
206
|
+
continue
|
|
207
|
+
_collect(node.session_id, node.node_id, text, claims, seen)
|
|
208
|
+
return claims
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def claims_from_pairs(
|
|
212
|
+
pairs: Iterable[tuple[str | None, str]],
|
|
213
|
+
session_id: str,
|
|
214
|
+
) -> list[Claim]:
|
|
215
|
+
"""Claims over pre-decoded ``(role, text)`` pairs — the adapter input.
|
|
216
|
+
|
|
217
|
+
``node_id`` is synthesized per pair so excerpts remain traceable back
|
|
218
|
+
to their position in the transcript.
|
|
219
|
+
"""
|
|
220
|
+
claims: list[Claim] = []
|
|
221
|
+
seen: set[tuple[str, str]] = set()
|
|
222
|
+
for node_id, (role, text) in enumerate(pairs, 1):
|
|
223
|
+
if role in _SKIP_ROLES:
|
|
224
|
+
continue
|
|
225
|
+
_collect(session_id, node_id, text, claims, seen)
|
|
226
|
+
return claims
|