research-assistant 0.1.0
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.
- package/LICENSE +21 -0
- package/README-ZH.md +115 -0
- package/README.md +124 -0
- package/npm/bin/research-assistant.js +70 -0
- package/npm/scripts/postinstall.js +112 -0
- package/package.json +38 -0
- package/pyproject.toml +84 -0
- package/src/research_assistant/__init__.py +7 -0
- package/src/research_assistant/__main__.py +6 -0
- package/src/research_assistant/assets/researcher_persona.md +94 -0
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
- package/src/research_assistant/cli.py +155 -0
- package/src/research_assistant/commands/__init__.py +7 -0
- package/src/research_assistant/commands/ask.py +54 -0
- package/src/research_assistant/commands/doctor.py +201 -0
- package/src/research_assistant/commands/fetch.py +99 -0
- package/src/research_assistant/commands/locate.py +71 -0
- package/src/research_assistant/commands/search.py +161 -0
- package/src/research_assistant/commands/setup.py +249 -0
- package/src/research_assistant/commands/skills.py +60 -0
- package/src/research_assistant/config.py +258 -0
- package/src/research_assistant/errors.py +106 -0
- package/src/research_assistant/fetch/__init__.py +20 -0
- package/src/research_assistant/fetch/browser.py +613 -0
- package/src/research_assistant/fetch/cfbypass.py +318 -0
- package/src/research_assistant/fetch/html2md.py +44 -0
- package/src/research_assistant/fetch/normal.py +69 -0
- package/src/research_assistant/fetch/search_engine.py +324 -0
- package/src/research_assistant/fetch/snapshot.py +50 -0
- package/src/research_assistant/http.py +118 -0
- package/src/research_assistant/installer.py +270 -0
- package/src/research_assistant/locate/__init__.py +5 -0
- package/src/research_assistant/locate/engine.py +289 -0
- package/src/research_assistant/loginstate/__init__.py +10 -0
- package/src/research_assistant/loginstate/daemon.py +145 -0
- package/src/research_assistant/loginstate/daemon_cli.py +21 -0
- package/src/research_assistant/loginstate/daemonctl.py +116 -0
- package/src/research_assistant/loginstate/extension/background.js +167 -0
- package/src/research_assistant/loginstate/extension/manifest.json +15 -0
- package/src/research_assistant/loginstate/extension/popup.html +31 -0
- package/src/research_assistant/loginstate/extension/popup.js +33 -0
- package/src/research_assistant/output.py +140 -0
- package/src/research_assistant/providers/__init__.py +10 -0
- package/src/research_assistant/providers/base.py +93 -0
- package/src/research_assistant/providers/browser.py +91 -0
- package/src/research_assistant/providers/context7.py +171 -0
- package/src/research_assistant/providers/exa.py +353 -0
- package/src/research_assistant/providers/firecrawl.py +644 -0
- package/src/research_assistant/providers/openai_compat.py +293 -0
- package/src/research_assistant/providers/registry.py +62 -0
- package/src/research_assistant/providers/tavily.py +346 -0
- package/src/research_assistant/proxy.py +58 -0
- package/src/research_assistant/targets.py +66 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""managed 文件安装器(因家异构,ADR-0008 / D7)。
|
|
2
|
+
|
|
3
|
+
- SKILL.md(bundled asset)相对通用,装各家 skill 目录。
|
|
4
|
+
- researcher agent 定义因家而异:Claude/Cursor=md+frontmatter、Codex=toml、Hermes=无(融入 skill)。
|
|
5
|
+
- 附带复制 MV3 扩展到 ~/.research-assistant/extension/(供用户在浏览器加载一次)。
|
|
6
|
+
|
|
7
|
+
幂等:重复 install 覆盖;status 对比 installed 与 bundled 内容判定 missing/stale/up-to-date/extra。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .errors import ResearchAssistantError
|
|
18
|
+
from .targets import AGENT_NAME, ALL_FAMILIES, SKILL_NAME, Family
|
|
19
|
+
|
|
20
|
+
_ASSET_ROOT = ("assets",)
|
|
21
|
+
_PERSONA_RESOURCE = ("assets", "researcher_persona.md")
|
|
22
|
+
_SKILL_RESOURCE = ("assets", "skills", SKILL_NAME, "SKILL.md")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# asset 读取
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_text(resource_path: tuple[str, ...]) -> str:
|
|
31
|
+
"""优先 importlib.resources(打包后),失败回退文件系统(dev 从 src 运行)。"""
|
|
32
|
+
try:
|
|
33
|
+
root = resources.files("research_assistant")
|
|
34
|
+
for part in resource_path:
|
|
35
|
+
root = root.joinpath(part)
|
|
36
|
+
return root.read_text(encoding="utf-8")
|
|
37
|
+
except Exception:
|
|
38
|
+
fs_root = Path(__file__).resolve().parents[1]
|
|
39
|
+
p = fs_root.joinpath(*resource_path)
|
|
40
|
+
return p.read_text(encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def persona_text() -> str:
|
|
44
|
+
return _read_text(_PERSONA_RESOURCE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def skill_text() -> str:
|
|
48
|
+
return _read_text(_SKILL_RESOURCE)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extension_source_dir() -> Path:
|
|
52
|
+
"""MV3 扩展源目录(bundled)。优先 importlib.resources,回退文件系统。"""
|
|
53
|
+
try:
|
|
54
|
+
from importlib import resources
|
|
55
|
+
|
|
56
|
+
root = resources.files("research_assistant").joinpath("loginstate", "extension")
|
|
57
|
+
if root.is_dir():
|
|
58
|
+
with resources.as_file(root) as p:
|
|
59
|
+
return Path(p)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
fs_root = Path(__file__).resolve().parent # src/research_assistant/
|
|
63
|
+
p = fs_root / "loginstate" / "extension"
|
|
64
|
+
if p.is_dir():
|
|
65
|
+
return p
|
|
66
|
+
raise ResearchAssistantError("MV3 扩展源未找到(loginstate/extension 缺失)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# 因家生成 agent 定义
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generate_agent(family: Family) -> str | None:
|
|
75
|
+
"""按家生成 researcher agent 定义内容;Hermes 返回 None(无独立文件)。"""
|
|
76
|
+
persona = persona_text()
|
|
77
|
+
if family.agent_format == "none":
|
|
78
|
+
return None
|
|
79
|
+
if family.agent_format == "toml":
|
|
80
|
+
return _generate_codex_toml(persona)
|
|
81
|
+
return _generate_agent_md(family, persona)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _frontmatter_line(value: Any) -> str:
|
|
85
|
+
if isinstance(value, str) and ("\n" in value or ":" in value):
|
|
86
|
+
# 多行/含冒号的值用引号包裹并转义内部引号(不用 f-string 内反斜杠,兼容 3.10)
|
|
87
|
+
escaped = value.replace('"', '\\"')
|
|
88
|
+
return '"' + escaped + '"'
|
|
89
|
+
return str(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _generate_agent_md(family: Family, persona: str) -> str:
|
|
93
|
+
fields: dict[str, Any] = {
|
|
94
|
+
"name": AGENT_NAME,
|
|
95
|
+
"description": (
|
|
96
|
+
"Use this sub-agent for research that needs external information (web search, docs, "
|
|
97
|
+
"page fetching). It runs in isolation and returns only a summary; the full report is on disk."
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
fields.update(family.md_fields)
|
|
101
|
+
fm_lines = ["---"]
|
|
102
|
+
for k, v in fields.items():
|
|
103
|
+
fm_lines.append(f"{k}: {_frontmatter_line(v)}")
|
|
104
|
+
fm_lines.append("---")
|
|
105
|
+
fm_lines.append("")
|
|
106
|
+
return "\n".join(fm_lines) + persona
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _generate_codex_toml(persona: str) -> str:
|
|
110
|
+
# Codex AgentRoleToml schema(openai/codex 源码确认,deepwiki 2026-07):
|
|
111
|
+
# 顶层合法字段:name / description / developer_instructions / model / nickname_candidates / config_file。
|
|
112
|
+
# sandbox 经子表配置([sandbox_workspace_write]),mcp_servers 属 config.toml 顶层 [mcp_servers] 表——
|
|
113
|
+
# 二者均非 agent 文件顶层字段。此前误写的 sandbox_mode / mcp_servers 顶层键已移除,避免 codex 解析告警。
|
|
114
|
+
# model 省略 → 继承 codex 全局 model 配置(不写非法的 "inherit" 字面值)。
|
|
115
|
+
# developer_instructions 用三引号 toml 字符串承载 persona。
|
|
116
|
+
return (
|
|
117
|
+
f'name = "{AGENT_NAME}"\n'
|
|
118
|
+
f'description = "Research sub-agent: web search, docs, page fetch. Runs in isolation, returns summary only."\n'
|
|
119
|
+
f'developer_instructions = """\n{persona.strip()}\n"""\n'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def skill_for_family(family: Family) -> str:
|
|
124
|
+
"""SKILL.md 内容。Hermes 把 researcher 人设融入(ADR-0008 D7)。"""
|
|
125
|
+
base = skill_text()
|
|
126
|
+
if family.name == "hermes":
|
|
127
|
+
# Hermes 无独立 agent 文件,把 persona 附到 skill 末尾
|
|
128
|
+
return base.rstrip() + "\n\n## researcher persona (runtime delegation)\n\n" + persona_text().strip() + "\n"
|
|
129
|
+
return base
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# 路径解析
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _home(root: str | Path | None) -> Path:
|
|
138
|
+
if root:
|
|
139
|
+
return Path(root).expanduser()
|
|
140
|
+
return Path.home()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def skill_path(family: Family, root: str | Path | None = None) -> Path:
|
|
144
|
+
return _home(root) / family.skill_relative / "SKILL.md"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def agent_path(family: Family, root: str | Path | None = None) -> Path | None:
|
|
148
|
+
if family.agent_relative is None:
|
|
149
|
+
return None
|
|
150
|
+
return _home(root) / family.agent_relative
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# install / status / update
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def install(families: list[str], *, root: str | Path | None = None) -> dict[str, Any]:
|
|
159
|
+
home = _home(root)
|
|
160
|
+
installed: list[dict[str, Any]] = []
|
|
161
|
+
failed: list[dict[str, str]] = []
|
|
162
|
+
for fname in families:
|
|
163
|
+
family = ALL_FAMILIES[fname]
|
|
164
|
+
try:
|
|
165
|
+
sp = skill_path(family, home)
|
|
166
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
sp.write_text(skill_for_family(family), encoding="utf-8")
|
|
168
|
+
entry: dict[str, Any] = {
|
|
169
|
+
"family": fname,
|
|
170
|
+
"skill_path": str(sp),
|
|
171
|
+
}
|
|
172
|
+
ap = agent_path(family, home)
|
|
173
|
+
if ap is not None:
|
|
174
|
+
ap.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
ap.write_text(generate_agent(family) or "", encoding="utf-8")
|
|
176
|
+
entry["agent_path"] = str(ap)
|
|
177
|
+
entry["agent_format"] = family.agent_format
|
|
178
|
+
else:
|
|
179
|
+
entry["agent_path"] = None
|
|
180
|
+
entry["agent_format"] = "none"
|
|
181
|
+
installed.append(entry)
|
|
182
|
+
except OSError as e:
|
|
183
|
+
failed.append({"family": fname, "error": str(e)})
|
|
184
|
+
|
|
185
|
+
# 附带复制 MV3 扩展(登录态层,ADR-0006)
|
|
186
|
+
extension_copied = False
|
|
187
|
+
extension_dest = None
|
|
188
|
+
try:
|
|
189
|
+
extension_dest = _copy_extension()
|
|
190
|
+
extension_copied = True
|
|
191
|
+
except Exception as e:
|
|
192
|
+
failed.append({"family": "extension", "error": str(e)})
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"root": str(home),
|
|
196
|
+
"installed": installed,
|
|
197
|
+
"failed": failed,
|
|
198
|
+
"extension_copied": extension_copied,
|
|
199
|
+
"extension_path": str(extension_dest) if extension_dest else None,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def status(families: list[str], *, root: str | Path | None = None) -> dict[str, Any]:
|
|
204
|
+
home = _home(root)
|
|
205
|
+
out: list[dict[str, Any]] = []
|
|
206
|
+
for fname in families:
|
|
207
|
+
family = ALL_FAMILIES[fname]
|
|
208
|
+
item = _status_family(family, home)
|
|
209
|
+
item["family"] = fname
|
|
210
|
+
out.append(item)
|
|
211
|
+
counts: dict[str, int] = {}
|
|
212
|
+
for it in out:
|
|
213
|
+
s = it["skill_status"]
|
|
214
|
+
counts[s] = counts.get(s, 0) + 1
|
|
215
|
+
return {"root": str(home), "targets": out, "status_counts": counts}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def update(families: list[str], *, root: str | Path | None = None) -> dict[str, Any]:
|
|
219
|
+
return install(families, root=root)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _status_family(family: Family, home: Path) -> dict[str, Any]:
|
|
223
|
+
sp = skill_path(family, home)
|
|
224
|
+
bundled_skill = skill_for_family(family)
|
|
225
|
+
skill_status = _compare(sp, bundled_skill)
|
|
226
|
+
result: dict[str, Any] = {
|
|
227
|
+
"skill_path": str(sp),
|
|
228
|
+
"skill_status": skill_status,
|
|
229
|
+
}
|
|
230
|
+
ap = agent_path(family, home)
|
|
231
|
+
if ap is not None:
|
|
232
|
+
bundled_agent = generate_agent(family) or ""
|
|
233
|
+
result["agent_path"] = str(ap)
|
|
234
|
+
result["agent_status"] = _compare(ap, bundled_agent)
|
|
235
|
+
else:
|
|
236
|
+
result["agent_path"] = None
|
|
237
|
+
result["agent_status"] = "none"
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _compare(path: Path, bundled: str) -> str:
|
|
242
|
+
"""对比 installed 与 bundled:missing/stale/up-to-date/extra。"""
|
|
243
|
+
if not path.exists():
|
|
244
|
+
return "missing"
|
|
245
|
+
try:
|
|
246
|
+
installed = path.read_text(encoding="utf-8")
|
|
247
|
+
except OSError:
|
|
248
|
+
return "missing"
|
|
249
|
+
if installed == bundled:
|
|
250
|
+
return "up-to-date"
|
|
251
|
+
return "stale"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# 扩展复制
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _copy_extension() -> Path:
|
|
260
|
+
"""把 bundled MV3 扩展复制到 ~/.research-assistant/extension/。"""
|
|
261
|
+
import shutil
|
|
262
|
+
|
|
263
|
+
from . import config as config_mod
|
|
264
|
+
|
|
265
|
+
src = extension_source_dir()
|
|
266
|
+
dest = config_mod.config_dir() / "extension"
|
|
267
|
+
if dest.exists():
|
|
268
|
+
shutil.rmtree(dest)
|
|
269
|
+
shutil.copytree(src, dest)
|
|
270
|
+
return dest
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""locate 包:锚点法速览预筛(PM §4.3 / ADR locate 非强制)。
|
|
2
|
+
|
|
3
|
+
锚点法:模型输出原文里实际存在的一句话(quote)+ 相关度;行号由代码字符串匹配算出
|
|
4
|
+
(模型擅长找关键句、代码擅长计数)。行号一律基于已落盘 md 副本(稳定性,基线 §7.4)。
|
|
5
|
+
|
|
6
|
+
流程:
|
|
7
|
+
读 md → 切块(paragraph / lines 窗口)→ 高并发小模型批量打分(输出 quote+relevance XML)
|
|
8
|
+
→ 代码匹配 quote 到行号区间 → top N + 周边 K 行 context
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import re
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..config import Config
|
|
20
|
+
from ..providers import openai_compat
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Chunk:
|
|
25
|
+
text: str
|
|
26
|
+
line_start: int # 1-based
|
|
27
|
+
line_end: int # 1-based
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Anchor:
|
|
32
|
+
quote: str
|
|
33
|
+
line_start: int
|
|
34
|
+
line_end: int
|
|
35
|
+
scope: str
|
|
36
|
+
context: str
|
|
37
|
+
relevance: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def chunk_md(text: str, scope: str, window: int = 40) -> list[Chunk]:
|
|
41
|
+
"""把 md 文本切成块。scope=paragraph 按空行分段;scope=lines 按固定行窗口。"""
|
|
42
|
+
lines = text.splitlines()
|
|
43
|
+
if scope == "lines":
|
|
44
|
+
chunks: list[Chunk] = []
|
|
45
|
+
i = 0
|
|
46
|
+
while i < len(lines):
|
|
47
|
+
block = lines[i : i + window]
|
|
48
|
+
if any(s.strip() for s in block):
|
|
49
|
+
chunks.append(Chunk("\n".join(block), i + 1, i + len(block)))
|
|
50
|
+
i += window
|
|
51
|
+
return chunks
|
|
52
|
+
# paragraph:按空行分段,记录行号
|
|
53
|
+
chunks: list[Chunk] = []
|
|
54
|
+
start = 0
|
|
55
|
+
buf: list[str] = []
|
|
56
|
+
for idx, line in enumerate(lines):
|
|
57
|
+
if line.strip() == "":
|
|
58
|
+
if any(s.strip() for s in buf):
|
|
59
|
+
chunks.append(Chunk("\n".join(buf), start + 1, start + len(buf)))
|
|
60
|
+
buf = []
|
|
61
|
+
start = idx + 1
|
|
62
|
+
else:
|
|
63
|
+
if not buf:
|
|
64
|
+
start = idx
|
|
65
|
+
buf.append(line)
|
|
66
|
+
if any(s.strip() for s in buf):
|
|
67
|
+
chunks.append(Chunk("\n".join(buf), start + 1, start + len(buf)))
|
|
68
|
+
return chunks
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_PROMPT_SYSTEM = (
|
|
72
|
+
"You locate the most relevant passage in a text chunk for a given question. "
|
|
73
|
+
"If (and only if) the chunk contains content relevant to the question, output an XML <anchor> with:\n"
|
|
74
|
+
" <quote>: an EXACT, verbatim sentence or phrase COPIED from the chunk (do not paraphrase).\n"
|
|
75
|
+
" <relevance>: a float 0.0-1.0.\n"
|
|
76
|
+
"If the chunk is irrelevant, output <anchors></anchors> with nothing inside. "
|
|
77
|
+
"Output ONLY the XML, no prose."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_user(query: str, chunk_text: str) -> str:
|
|
82
|
+
return (
|
|
83
|
+
f"Question: {query}\n\n"
|
|
84
|
+
f"Chunk:\n\"\"\"\n{chunk_text}\n\"\"\"\n\n"
|
|
85
|
+
f"Output the <anchors> XML now."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_ANCHOR_RE = re.compile(r"<anchor>(.*?)</anchor>", re.DOTALL)
|
|
90
|
+
_QUOTE_RE = re.compile(r"<quote>(.*?)</quote>", re.DOTALL)
|
|
91
|
+
_REL_RE = re.compile(r"<relevance>(.*?)</relevance>", re.DOTALL)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_anchors_xml(content: str) -> list[tuple[str, float]]:
|
|
95
|
+
"""从模型输出解析 (quote, relevance) 列表(容忍外层包裹的非 XML 文本)。"""
|
|
96
|
+
results: list[tuple[str, float]] = []
|
|
97
|
+
for m in _ANCHOR_RE.finditer(content):
|
|
98
|
+
block = m.group(1)
|
|
99
|
+
q = _QUOTE_RE.search(block)
|
|
100
|
+
r = _REL_RE.search(block)
|
|
101
|
+
if not q:
|
|
102
|
+
continue
|
|
103
|
+
quote = _decode_xml_text(q.group(1)).strip()
|
|
104
|
+
if not quote:
|
|
105
|
+
continue
|
|
106
|
+
rel = 0.5
|
|
107
|
+
if r:
|
|
108
|
+
try:
|
|
109
|
+
rel = float(_decode_xml_text(r.group(1)).strip())
|
|
110
|
+
except ValueError:
|
|
111
|
+
rel = 0.5
|
|
112
|
+
results.append((quote, max(0.0, min(1.0, rel))))
|
|
113
|
+
# 兜底:用 ElementTree 再试一次(模型偶尔回合法 XML)
|
|
114
|
+
if not results:
|
|
115
|
+
try:
|
|
116
|
+
root = ET.fromstring(content.strip())
|
|
117
|
+
for a in root.iter("anchor"):
|
|
118
|
+
q = a.findtext("quote")
|
|
119
|
+
r = a.findtext("relevance")
|
|
120
|
+
if q and q.strip():
|
|
121
|
+
try:
|
|
122
|
+
rel = float(r) if r else 0.5
|
|
123
|
+
except ValueError:
|
|
124
|
+
rel = 0.5
|
|
125
|
+
results.append((q.strip(), max(0.0, min(1.0, rel))))
|
|
126
|
+
except ET.ParseError:
|
|
127
|
+
pass
|
|
128
|
+
return results
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _decode_xml_text(s: str) -> str:
|
|
132
|
+
return (
|
|
133
|
+
s.replace("<", "<")
|
|
134
|
+
.replace(">", ">")
|
|
135
|
+
.replace("&", "&")
|
|
136
|
+
.replace(""", '"')
|
|
137
|
+
.replace("'", "'")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _normalize_ws(s: str) -> str:
|
|
142
|
+
return " ".join(s.split())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def find_quote_lines(full_text: str, quote: str) -> tuple[int, int] | None:
|
|
146
|
+
"""在全文中定位 quote 的行号区间(1-based)。归一化空白后做子串匹配。
|
|
147
|
+
|
|
148
|
+
模型可能微调标点/空白,故采用归一化匹配;找不到返回 None。
|
|
149
|
+
"""
|
|
150
|
+
norm_quote = _normalize_ws(quote)
|
|
151
|
+
if not norm_quote:
|
|
152
|
+
return None
|
|
153
|
+
# 取 quote 的特征前缀/后缀(避免模型夹带多余字)
|
|
154
|
+
lines = full_text.splitlines()
|
|
155
|
+
norm_lines = [_normalize_ws(l) for l in lines]
|
|
156
|
+
joined = "\n".join(norm_lines)
|
|
157
|
+
|
|
158
|
+
# 直接整体匹配
|
|
159
|
+
pos = _fuzzy_find(joined, norm_quote)
|
|
160
|
+
if pos is None:
|
|
161
|
+
# 退化为首句匹配(取 quote 第一个句子)
|
|
162
|
+
first_sentence = re.split(r"(?<=[。.!??])\s", norm_quote)
|
|
163
|
+
probe = first_sentence[0] if first_sentence else norm_quote[:60]
|
|
164
|
+
if len(probe) < 8:
|
|
165
|
+
return None
|
|
166
|
+
pos = _fuzzy_find(joined, probe)
|
|
167
|
+
if pos is None:
|
|
168
|
+
return None
|
|
169
|
+
# 把字符偏移映射回行号(基于 joined 的换行结构)
|
|
170
|
+
return _offset_to_lines(joined, pos, norm_quote)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _fuzzy_find(haystack: str, needle: str) -> int | None:
|
|
174
|
+
"""在归一化文本里找 needle;先精确,失败则用最长 token 序列近似。"""
|
|
175
|
+
idx = haystack.find(needle)
|
|
176
|
+
if idx >= 0:
|
|
177
|
+
return idx
|
|
178
|
+
# 容忍 needle 中间多了少量字符:取首尾各 ~20 字符做定位
|
|
179
|
+
if len(needle) > 40:
|
|
180
|
+
head, tail = needle[:20], needle[-20:]
|
|
181
|
+
hi = haystack.find(head)
|
|
182
|
+
if hi >= 0:
|
|
183
|
+
tj = haystack.find(tail, hi)
|
|
184
|
+
if tj >= 0:
|
|
185
|
+
return hi
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _offset_to_lines(joined: str, pos: int, needle: str) -> tuple[int, int]:
|
|
190
|
+
"""joined 是 '\\n'.join(norm_lines);把 [pos, pos+len) 映射到 1-based 行号。"""
|
|
191
|
+
# 计算每行起始偏移
|
|
192
|
+
line_starts = [0]
|
|
193
|
+
for i, ch in enumerate(joined):
|
|
194
|
+
if ch == "\n":
|
|
195
|
+
line_starts.append(i + 1)
|
|
196
|
+
end = min(pos + len(needle), len(joined))
|
|
197
|
+
start_line = _bisect_line(line_starts, pos)
|
|
198
|
+
end_line = _bisect_line(line_starts, max(pos, end - 1))
|
|
199
|
+
return start_line + 1, end_line + 1
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _bisect_line(line_starts: list[int], offset: int) -> int:
|
|
203
|
+
# 最大 i 使 line_starts[i] <= offset
|
|
204
|
+
import bisect
|
|
205
|
+
|
|
206
|
+
return bisect.bisect_right(line_starts, offset) - 1
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_context(lines: list[str], line_start: int, line_end: int, k: int) -> str:
|
|
210
|
+
"""取 [line_start, line_end] 周围各 K 行的上下文文本。"""
|
|
211
|
+
lo = max(1, line_start - k)
|
|
212
|
+
hi = min(len(lines), line_end + k)
|
|
213
|
+
return "\n".join(lines[lo - 1 : hi])
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def locate(
|
|
217
|
+
config: Config,
|
|
218
|
+
md_text: str,
|
|
219
|
+
query: str,
|
|
220
|
+
*,
|
|
221
|
+
scope: str = "paragraph",
|
|
222
|
+
top: int = 5,
|
|
223
|
+
context_k: int = 3,
|
|
224
|
+
concurrency: int | None = None,
|
|
225
|
+
) -> list[Anchor]:
|
|
226
|
+
"""主入口:对 md_text 跑锚点定位,返回 top N anchors(按 relevance 降序)。"""
|
|
227
|
+
if concurrency is None:
|
|
228
|
+
loc_cfg = config.provider("locate")
|
|
229
|
+
concurrency = loc_cfg.concurrency if loc_cfg else 8
|
|
230
|
+
|
|
231
|
+
chunks = chunk_md(md_text, scope)
|
|
232
|
+
if not chunks:
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
236
|
+
|
|
237
|
+
async def score_one(chunk: Chunk) -> list[tuple[str, float, Chunk]]:
|
|
238
|
+
async with sem:
|
|
239
|
+
resp = await openai_compat.chat_completion(
|
|
240
|
+
config,
|
|
241
|
+
[
|
|
242
|
+
{"role": "system", "content": _PROMPT_SYSTEM},
|
|
243
|
+
{"role": "user", "content": _build_user(query, chunk.text)},
|
|
244
|
+
],
|
|
245
|
+
provider_type="locate",
|
|
246
|
+
temperature=0.0,
|
|
247
|
+
)
|
|
248
|
+
msg = openai_compat.extract_message(resp)
|
|
249
|
+
content = openai_compat.strip_think(msg.get("content") or "")
|
|
250
|
+
if isinstance(content, list):
|
|
251
|
+
content = "\n".join(seg.get("text", "") for seg in content if isinstance(seg, dict))
|
|
252
|
+
parsed = parse_anchors_xml(content)
|
|
253
|
+
return [(q, r, chunk) for q, r in parsed]
|
|
254
|
+
|
|
255
|
+
batch_results = await asyncio.gather(*[score_one(c) for c in chunks], return_exceptions=True)
|
|
256
|
+
|
|
257
|
+
lines = md_text.splitlines()
|
|
258
|
+
anchors: list[Anchor] = []
|
|
259
|
+
for res in batch_results:
|
|
260
|
+
if isinstance(res, Exception):
|
|
261
|
+
continue
|
|
262
|
+
for quote, rel, chunk in res:
|
|
263
|
+
span = find_quote_lines(md_text, quote)
|
|
264
|
+
if span is None:
|
|
265
|
+
# 退回 chunk 自身范围(行号稳定性优先用全文匹配,失败才回退)
|
|
266
|
+
ls, le = chunk.line_start, chunk.line_end
|
|
267
|
+
else:
|
|
268
|
+
ls, le = span
|
|
269
|
+
anchors.append(
|
|
270
|
+
Anchor(
|
|
271
|
+
quote=quote,
|
|
272
|
+
line_start=ls,
|
|
273
|
+
line_end=le,
|
|
274
|
+
scope=scope,
|
|
275
|
+
context=build_context(lines, ls, le, context_k),
|
|
276
|
+
relevance=rel,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# 去重(相同行区间)+ 排序 + top N
|
|
281
|
+
seen: set[tuple[int, int]] = set()
|
|
282
|
+
unique: list[Anchor] = []
|
|
283
|
+
for a in sorted(anchors, key=lambda x: x.relevance, reverse=True):
|
|
284
|
+
key = (a.line_start, a.line_end)
|
|
285
|
+
if key in seen:
|
|
286
|
+
continue
|
|
287
|
+
seen.add(key)
|
|
288
|
+
unique.append(a)
|
|
289
|
+
return unique[:top]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""登录态层(ADR-0006):内嵌 cdt 扩展桥。
|
|
2
|
+
|
|
3
|
+
- MV3 扩展(bundled,装在用户日常浏览器)用 chrome.cookies 读明文 cookie(绕 ABE)→ 推 daemon。
|
|
4
|
+
- cookie daemon(Python 移植自 cdt)缓存并暴露 /cookies。
|
|
5
|
+
- fetch 启动时取 cookie,add_cookies 注入隔离 profile(避 lock)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .daemonctl import daemon_status, ensure_daemon, get_cookies, to_playwright_cookies
|
|
9
|
+
|
|
10
|
+
__all__ = ["daemon_status", "ensure_daemon", "get_cookies", "to_playwright_cookies"]
|