docsweep 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.
- docsweep/__init__.py +3 -0
- docsweep/__main__.py +4 -0
- docsweep/activity.py +183 -0
- docsweep/aggregate_index.py +148 -0
- docsweep/archive.py +80 -0
- docsweep/atomic.py +181 -0
- docsweep/auto_triage.py +172 -0
- docsweep/brief/__init__.py +9 -0
- docsweep/brief/score.py +132 -0
- docsweep/brief/service.py +243 -0
- docsweep/capture/__init__.py +15 -0
- docsweep/capture/heuristics.py +69 -0
- docsweep/capture/llm.py +152 -0
- docsweep/capture/models.py +29 -0
- docsweep/capture/service.py +79 -0
- docsweep/claim.py +112 -0
- docsweep/cli.py +1771 -0
- docsweep/completion.py +157 -0
- docsweep/config.py +418 -0
- docsweep/context.py +160 -0
- docsweep/cross/__init__.py +11 -0
- docsweep/cross/service.py +207 -0
- docsweep/detect.py +328 -0
- docsweep/engine.py +312 -0
- docsweep/export.py +287 -0
- docsweep/find.py +133 -0
- docsweep/graph/__init__.py +8 -0
- docsweep/graph/service.py +106 -0
- docsweep/index.py +521 -0
- docsweep/inject.py +654 -0
- docsweep/interactive.py +344 -0
- docsweep/linkcheck.py +231 -0
- docsweep/mcp_server.py +471 -0
- docsweep/migrate.py +167 -0
- docsweep/models.py +94 -0
- docsweep/presets.py +56 -0
- docsweep/related.py +175 -0
- docsweep/reports.py +315 -0
- docsweep/resurrect/__init__.py +12 -0
- docsweep/resurrect/embedding.py +43 -0
- docsweep/resurrect/service.py +184 -0
- docsweep/resurrect/similarity.py +38 -0
- docsweep/review.py +69 -0
- docsweep/scan.py +431 -0
- docsweep/security/__init__.py +13 -0
- docsweep/security/path.py +65 -0
- docsweep/server/__init__.py +5 -0
- docsweep/server/app.py +319 -0
- docsweep/server/routes/__init__.py +11 -0
- docsweep/server/routes/board.py +456 -0
- docsweep/server/routes/brief.py +66 -0
- docsweep/server/routes/capture.py +108 -0
- docsweep/server/routes/cards.py +436 -0
- docsweep/server/routes/cross.py +62 -0
- docsweep/server/routes/graph.py +54 -0
- docsweep/server/routes/resurrect.py +64 -0
- docsweep/server/sanitize.py +144 -0
- docsweep/server/security.py +47 -0
- docsweep/server/static/board.css +640 -0
- docsweep/server/static/dnd.js +116 -0
- docsweep/server/static/edit.js +258 -0
- docsweep/server/static/htmx.min.js +1 -0
- docsweep/server/static/icons/apple-touch-icon.png +0 -0
- docsweep/server/static/icons/favicon.ico +0 -0
- docsweep/server/static/icons/favicon.svg +30 -0
- docsweep/server/static/icons/icon-192.png +0 -0
- docsweep/server/static/icons/icon-512.png +0 -0
- docsweep/server/static/keymap.js +1123 -0
- docsweep/server/templates/_board_body.html +102 -0
- docsweep/server/templates/_card.html +68 -0
- docsweep/server/templates/_change_picker.html +27 -0
- docsweep/server/templates/_due_picker.html +13 -0
- docsweep/server/templates/_edit_pane.html +73 -0
- docsweep/server/templates/_label_picker.html +14 -0
- docsweep/server/templates/_preview.html +15 -0
- docsweep/server/templates/_settings.html +68 -0
- docsweep/server/templates/board.html +111 -0
- docsweep/server/templates/brief.html +89 -0
- docsweep/server/templates/capture.html +121 -0
- docsweep/server/templates/cross.html +74 -0
- docsweep/server/templates/graph.html +99 -0
- docsweep/server/templates/resurrect.html +58 -0
- docsweep/services/__init__.py +11 -0
- docsweep/services/archive.py +240 -0
- docsweep/services/content.py +68 -0
- docsweep/services/due.py +189 -0
- docsweep/services/frontmatter.py +241 -0
- docsweep/services/status.py +157 -0
- docsweep/stale.py +117 -0
- docsweep/state.py +198 -0
- docsweep/states.py +165 -0
- docsweep/templates_gen.py +178 -0
- docsweep/timeline.py +177 -0
- docsweep-0.1.0.dist-info/METADATA +484 -0
- docsweep-0.1.0.dist-info/RECORD +99 -0
- docsweep-0.1.0.dist-info/WHEEL +4 -0
- docsweep-0.1.0.dist-info/entry_points.txt +2 -0
- docsweep-0.1.0.dist-info/licenses/LICENSE +21 -0
- docsweep-0.1.0.dist-info/licenses/NOTICES.md +47 -0
docsweep/auto_triage.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""C5: auto-triage — md の状態遷移提案を LLM に委譲し、承認済みを一括適用する。
|
|
2
|
+
|
|
3
|
+
設計:
|
|
4
|
+
- ``suggest`` モード: md 本文 + 索引メタ + linkcheck 結果 を LLM に渡して
|
|
5
|
+
「[完了] / 維持 / [廃止] / 不明」と根拠を返してもらう
|
|
6
|
+
- ``apply`` モード: 提案を ``engine.apply_action`` で一括実行する
|
|
7
|
+
|
|
8
|
+
実 LLM は本 plan では呼ばない。``ruleset`` ベースの decider(フラグから推測)と
|
|
9
|
+
Mock LLM 経路の両方を用意する。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .engine import apply_action, run_scan
|
|
18
|
+
from .linkcheck import linkcheck
|
|
19
|
+
from .models import Action, FileRecord, Flag
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TriageSuggestion:
|
|
24
|
+
"""1 ファイル分の遷移提案。"""
|
|
25
|
+
|
|
26
|
+
path: str
|
|
27
|
+
project: str
|
|
28
|
+
current_state: str | None
|
|
29
|
+
proposed_action: str # "discard" / "keep" / "resume" / "relabel" / "promote" / "skip"
|
|
30
|
+
proposed_to: str | None # relabel 時の宛先ラベル名(例 "[完了]")
|
|
31
|
+
reason: str
|
|
32
|
+
confidence: float # 0..1
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
return asdict(self)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class TriageSuggestResult:
|
|
40
|
+
suggestions: list[TriageSuggestion] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
return {"suggestions": [s.to_dict() for s in self.suggestions]}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ruleset_decide(rec: FileRecord, lc_progress: str | None) -> TriageSuggestion | None:
|
|
47
|
+
"""ヒューリスティック decider。LLM 不要の最小実装。
|
|
48
|
+
|
|
49
|
+
判定ルール:
|
|
50
|
+
- linkcheck progress = "implemented" の plan → "promote" (完了候補)
|
|
51
|
+
- NEEDS_DECISION + age > 180 → "discard" 候補(陳腐化が長すぎる)
|
|
52
|
+
- state=watching + age > 14 → "promote" (release sweep 候補)
|
|
53
|
+
- それ以外 → 提案無し
|
|
54
|
+
"""
|
|
55
|
+
flags = set(rec.flags or [])
|
|
56
|
+
age = rec.age_days or 0
|
|
57
|
+
|
|
58
|
+
if rec.type == "plan" and lc_progress == "implemented" and rec.state in {"planned", "in-progress"}:
|
|
59
|
+
return TriageSuggestion(
|
|
60
|
+
path=rec.path, project=rec.project, current_state=rec.state,
|
|
61
|
+
proposed_action="relabel",
|
|
62
|
+
proposed_to="[完了]",
|
|
63
|
+
reason="linkcheck で「変更予定ファイル」がほぼ実装済み + commit 言及あり",
|
|
64
|
+
confidence=0.75,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if Flag.NEEDS_DECISION.value in flags and age > 180:
|
|
68
|
+
return TriageSuggestion(
|
|
69
|
+
path=rec.path, project=rec.project, current_state=rec.state,
|
|
70
|
+
proposed_action="discard",
|
|
71
|
+
proposed_to=None,
|
|
72
|
+
reason=f"陳腐化フラグが立ってから 180 日超 (age={age}d) — 廃止判断を提案",
|
|
73
|
+
confidence=0.6,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if rec.state == "watching" and age > 14:
|
|
77
|
+
return TriageSuggestion(
|
|
78
|
+
path=rec.path, project=rec.project, current_state=rec.state,
|
|
79
|
+
proposed_action="promote",
|
|
80
|
+
proposed_to=None,
|
|
81
|
+
reason=f"様子見 → 完了 へ昇格候補 (age={age}d, release sweep に該当)",
|
|
82
|
+
confidence=0.5,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def suggest_transitions(
|
|
89
|
+
config: Config, *, target: str | None = None,
|
|
90
|
+
) -> TriageSuggestResult:
|
|
91
|
+
"""状態遷移提案を生成する。
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
config: ロード済み Config
|
|
95
|
+
target: 単一ファイルの相対パス / basename。None で全件
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
``TriageSuggestResult``。
|
|
99
|
+
"""
|
|
100
|
+
# linkcheck 結果を map にまとめておく(plan 進捗を判断材料に)
|
|
101
|
+
lc_map: dict[str, str] = {}
|
|
102
|
+
for lc in linkcheck(config):
|
|
103
|
+
lc_map[lc.plan_path] = lc.progress_hint
|
|
104
|
+
|
|
105
|
+
result = run_scan(config)
|
|
106
|
+
suggestions: list[TriageSuggestion] = []
|
|
107
|
+
for doc in result.docs:
|
|
108
|
+
rec = doc.record
|
|
109
|
+
if target:
|
|
110
|
+
from pathlib import Path
|
|
111
|
+
t = Path(target)
|
|
112
|
+
if rec.path != str(t) and Path(rec.path).name != t.name:
|
|
113
|
+
continue
|
|
114
|
+
s = _ruleset_decide(rec, lc_map.get(rec.path))
|
|
115
|
+
if s is not None:
|
|
116
|
+
suggestions.append(s)
|
|
117
|
+
return TriageSuggestResult(suggestions=suggestions)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class ApplyResult:
|
|
122
|
+
applied: list[dict] = field(default_factory=list)
|
|
123
|
+
skipped: list[dict] = field(default_factory=list)
|
|
124
|
+
failed: list[dict] = field(default_factory=list)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict:
|
|
127
|
+
return asdict(self)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_suggestions(
|
|
131
|
+
config: Config,
|
|
132
|
+
decisions: list[dict],
|
|
133
|
+
*,
|
|
134
|
+
dry_run: bool = False,
|
|
135
|
+
) -> ApplyResult:
|
|
136
|
+
"""承認済み提案を一括適用する。
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
config: ロード済み Config
|
|
140
|
+
decisions: ``[{path, action, to?}]`` のリスト。CLI / MCP で承認したものを渡す
|
|
141
|
+
dry_run: True で実際の移送/書換は行わず計画だけ返す
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
``ApplyResult``。
|
|
145
|
+
"""
|
|
146
|
+
result = run_scan(config)
|
|
147
|
+
by_path = {d.record.path: d for d in result.docs}
|
|
148
|
+
applied: list[dict] = []
|
|
149
|
+
skipped: list[dict] = []
|
|
150
|
+
failed: list[dict] = []
|
|
151
|
+
|
|
152
|
+
for d in decisions:
|
|
153
|
+
path = d.get("path")
|
|
154
|
+
action = d.get("action")
|
|
155
|
+
to = d.get("to")
|
|
156
|
+
if not path or not action:
|
|
157
|
+
failed.append({"path": path, "reason": "path/action 欠落"})
|
|
158
|
+
continue
|
|
159
|
+
doc = by_path.get(path)
|
|
160
|
+
if doc is None:
|
|
161
|
+
failed.append({"path": path, "reason": "対象ファイルが見つからない"})
|
|
162
|
+
continue
|
|
163
|
+
if action == "skip":
|
|
164
|
+
skipped.append({"path": path})
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
entry = apply_action(doc, action, config, to=to, dry_run=dry_run)
|
|
168
|
+
applied.append(entry.to_dict())
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
failed.append({"path": path, "reason": str(e)})
|
|
171
|
+
|
|
172
|
+
return ApplyResult(applied=applied, skipped=skipped, failed=failed)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""C3 (wings): brief — 「今日の 1 個」を断定する出口の双子(CLI / Web 共通基盤)。
|
|
2
|
+
|
|
3
|
+
主役は :func:`service.build_brief` と :func:`score.score_record`。CLI/Web/MCP は全部この
|
|
4
|
+
2 関数を経由する(再実装しない)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .service import BriefResult, build_brief
|
|
8
|
+
|
|
9
|
+
__all__ = ["BriefResult", "build_brief"]
|
docsweep/brief/score.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""brief / cross 共通のスコア式(C3 + C4 共有モジュール)。
|
|
2
|
+
|
|
3
|
+
「今日の 1 個」を決めるための単一スコア式を 1 か所に集める。式は ``score_record(rec)`` で
|
|
4
|
+
1 つの FileRecord に対して float を返す。式自体は **断定する** ことを優先しており、
|
|
5
|
+
同点時はタイブレーカ(``rec.path`` 昇順)でも必ず 1 件が決まる。
|
|
6
|
+
|
|
7
|
+
設計指針:
|
|
8
|
+
- 入力は ``FileRecord`` のみ(索引から復元された FileRecord でもそのまま動く)
|
|
9
|
+
- 外部 LLM を呼ばない(決定性・速度のため)
|
|
10
|
+
- フィールドの欠落は 0 として扱い、例外を投げない
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import date
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..models import FileRecord, Flag
|
|
20
|
+
|
|
21
|
+
# スコア重み — 「今日の 1 個」の意思決定要素ごとの寄与度。値の妥当性はテストで固定する。
|
|
22
|
+
W_URGENCY = 40.0 # due 切れ・要判断(陳腐化)への重み
|
|
23
|
+
W_STALE = 1.5 # 経過日数の寄与(1 日 = 1.5 点)
|
|
24
|
+
W_REVIEW_STATUS = 5.0 # review_status 別の追加点(review > draft > published)
|
|
25
|
+
W_TOUCHED_DECAY = 0.3 # 直近触ったものを下げる(同じファイルに張り付かない誘導)
|
|
26
|
+
W_DEP_CHAIN = 8.0 # related の本数(プロジェクト中枢ファイルを浮かせる)
|
|
27
|
+
|
|
28
|
+
# stale 寄与の上限日数(無制限だと古い凍結ファイルが top_pick を独占してしまう)。
|
|
29
|
+
# 30 日 = 最大 45 点。urgency boost (= NEEDS_DECISION 40 点) と同等のオーダーになる。
|
|
30
|
+
STALE_DAY_CAP = 30
|
|
31
|
+
|
|
32
|
+
# state ごとの基礎点。done / discarded は brief の対象外として 0 にする
|
|
33
|
+
# (これらは archive 候補なので brief には浮かべない)。
|
|
34
|
+
_STATE_BASE: dict[str, float] = {
|
|
35
|
+
"in-progress": 12.0,
|
|
36
|
+
"planned": 10.0,
|
|
37
|
+
"watching": 6.0,
|
|
38
|
+
"pending": 5.0,
|
|
39
|
+
"done": 0.0,
|
|
40
|
+
"discarded": 0.0,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_REVIEW_STATUS_WEIGHT: dict[str, float] = {
|
|
44
|
+
"review": 2.0, # 「レビュー待ち」は催促したい
|
|
45
|
+
"draft": 1.0,
|
|
46
|
+
"published": 0.0,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ScoreBreakdown:
|
|
52
|
+
"""スコア内訳。``brief --explain`` / Web で内訳を見せるときに使う。"""
|
|
53
|
+
|
|
54
|
+
total: float
|
|
55
|
+
state_base: float
|
|
56
|
+
urgency: float
|
|
57
|
+
stale: float
|
|
58
|
+
review_status: float
|
|
59
|
+
touched_decay: float
|
|
60
|
+
dep_chain: float
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"total": round(self.total, 2),
|
|
65
|
+
"state_base": round(self.state_base, 2),
|
|
66
|
+
"urgency": round(self.urgency, 2),
|
|
67
|
+
"stale": round(self.stale, 2),
|
|
68
|
+
"review_status": round(self.review_status, 2),
|
|
69
|
+
"touched_decay": round(self.touched_decay, 2),
|
|
70
|
+
"dep_chain": round(self.dep_chain, 2),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _urgency_score(rec: FileRecord, *, today: date | None = None) -> float:
|
|
75
|
+
"""due 超過 / NEEDS_DECISION (陳腐化) を urgency 軸として加点する。
|
|
76
|
+
|
|
77
|
+
NEEDS_DECISION は「陳腐化した未終端 = 即判断が要る」を意味する強いシグナル。
|
|
78
|
+
stale (経過日数の連続値) より優先するため、40 点をつける(W_URGENCY 同等)。
|
|
79
|
+
"""
|
|
80
|
+
score = 0.0
|
|
81
|
+
base = today or date.today()
|
|
82
|
+
if rec.due:
|
|
83
|
+
try:
|
|
84
|
+
d = date.fromisoformat(rec.due)
|
|
85
|
+
days_over = (base - d).days
|
|
86
|
+
if days_over > 0:
|
|
87
|
+
# 1 日 1 点、上限 +30 まで(青天井にしない)
|
|
88
|
+
score += min(30.0, float(days_over))
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
if Flag.NEEDS_DECISION.value in (rec.flags or []):
|
|
92
|
+
score += 40.0 # 強いシグナル: stale cap 寄与より大きく
|
|
93
|
+
if Flag.OVERDUE_TODO.value in (rec.flags or []):
|
|
94
|
+
score += 20.0
|
|
95
|
+
if Flag.OVERDUE_GRADUATE.value in (rec.flags or []):
|
|
96
|
+
score += 12.0
|
|
97
|
+
return score
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def score_record(rec: FileRecord, *, today: date | None = None) -> ScoreBreakdown:
|
|
101
|
+
"""FileRecord を入力に ``ScoreBreakdown`` を返す。``total`` が大きいほど「今日の 1 個」候補。
|
|
102
|
+
|
|
103
|
+
done / discarded はスコア 0 で固定(brief / cross の対象外)。
|
|
104
|
+
"""
|
|
105
|
+
state_base = _STATE_BASE.get(rec.state or "", 0.0)
|
|
106
|
+
if state_base == 0.0 and rec.state in {"done", "discarded"}:
|
|
107
|
+
return ScoreBreakdown(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
|
108
|
+
|
|
109
|
+
urgency = _urgency_score(rec, today=today) * (W_URGENCY / 40.0)
|
|
110
|
+
# stale は cap 付き(古い凍結ファイルが top_pick を独占しないよう抑える)。
|
|
111
|
+
capped_age = min(float(rec.age_days or 0), float(STALE_DAY_CAP))
|
|
112
|
+
stale = capped_age * W_STALE
|
|
113
|
+
review_status = _REVIEW_STATUS_WEIGHT.get(rec.review_status or "", 0.0) * W_REVIEW_STATUS
|
|
114
|
+
# 触ったばかりのファイルは下げる(同じものに張り付かない誘導)
|
|
115
|
+
touched_decay = max(0.0, 14.0 - float(rec.age_days or 0)) * W_TOUCHED_DECAY
|
|
116
|
+
dep_chain = float(len(rec.related or [])) * W_DEP_CHAIN
|
|
117
|
+
|
|
118
|
+
total = state_base + urgency + stale + review_status - touched_decay + dep_chain
|
|
119
|
+
return ScoreBreakdown(
|
|
120
|
+
total=total,
|
|
121
|
+
state_base=state_base,
|
|
122
|
+
urgency=urgency,
|
|
123
|
+
stale=stale,
|
|
124
|
+
review_status=review_status,
|
|
125
|
+
touched_decay=-touched_decay,
|
|
126
|
+
dep_chain=dep_chain,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def tiebreak_key(rec: FileRecord) -> tuple:
|
|
131
|
+
"""同点時のタイブレーカ。決定性を確保するため ``path`` 昇順で 1 件に絞る。"""
|
|
132
|
+
return (rec.project or "", Path(rec.path).name, rec.path)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""C3: brief 生成のコアロジック。CLI / Web / MCP がすべてここを呼ぶ。
|
|
2
|
+
|
|
3
|
+
brief は「今日 1 個だけやろう」を断定するための朝の入口。表示要素は 4 つ:
|
|
4
|
+
|
|
5
|
+
1. ``today_pick``: 「今日の 1 個」(最高スコア・必ず 1 件決まる)
|
|
6
|
+
2. ``co_running``: 併走中(in-progress 上位 ~3 件、today_pick を除く)
|
|
7
|
+
3. ``watchouts``: 要注意 stale(NEEDS_DECISION / OVERDUE_TODO 等を持つ古い未終端)
|
|
8
|
+
4. ``yesterday_done``: 昨日終わったこと(mtime が 24h 以内の done/discarded)
|
|
9
|
+
|
|
10
|
+
すべてプロジェクト粒度の概念で、``--project all`` 時は project_id ごとに同じ束を作る。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from datetime import date, datetime, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..config import Config
|
|
20
|
+
from ..engine import scan_records
|
|
21
|
+
from ..models import FileRecord, Flag
|
|
22
|
+
from .score import ScoreBreakdown, score_record, tiebreak_key
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _short_record(rec: FileRecord, score: ScoreBreakdown | None = None) -> dict:
|
|
26
|
+
"""brief 表示で使う slim 表現。冗長な path は basename を併記して人間にも AI にも読める形に。"""
|
|
27
|
+
out = {
|
|
28
|
+
"path": rec.path,
|
|
29
|
+
"rel": Path(rec.path).name,
|
|
30
|
+
"project": rec.project,
|
|
31
|
+
"type": rec.type,
|
|
32
|
+
"state": rec.state,
|
|
33
|
+
"state_label": rec.state_label,
|
|
34
|
+
"title": rec.title,
|
|
35
|
+
"summary": rec.summary,
|
|
36
|
+
"age_days": rec.age_days,
|
|
37
|
+
"due": rec.due,
|
|
38
|
+
"owner": rec.owner,
|
|
39
|
+
"flags": list(rec.flags or []),
|
|
40
|
+
"tags": list(rec.tags or []),
|
|
41
|
+
}
|
|
42
|
+
if score is not None:
|
|
43
|
+
out["score"] = score.to_dict()
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ProjectBrief:
|
|
49
|
+
"""1 プロジェクト分の brief。``BriefResult.projects`` に複数並ぶ。"""
|
|
50
|
+
|
|
51
|
+
project: str
|
|
52
|
+
today_pick: dict | None
|
|
53
|
+
co_running: list[dict] = field(default_factory=list)
|
|
54
|
+
watchouts: list[dict] = field(default_factory=list)
|
|
55
|
+
yesterday_done: list[dict] = field(default_factory=list)
|
|
56
|
+
open_count: int = 0
|
|
57
|
+
stale_count: int = 0
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict:
|
|
60
|
+
return asdict(self)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BriefResult:
|
|
65
|
+
"""brief の最終出力。CLI/Web/MCP 共通。"""
|
|
66
|
+
|
|
67
|
+
mode: str # "single" | "all"
|
|
68
|
+
generated_at: str
|
|
69
|
+
projects: list[ProjectBrief] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"mode": self.mode,
|
|
74
|
+
"generated_at": self.generated_at,
|
|
75
|
+
"projects": [p.to_dict() for p in self.projects],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_open_state(rec: FileRecord) -> bool:
|
|
80
|
+
"""brief の主要対象(done/discarded 以外の未終端)。"""
|
|
81
|
+
return rec.state in {"in-progress", "planned", "watching", "pending"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _yesterday_window(now: datetime) -> tuple[float, float]:
|
|
85
|
+
"""直近 24h ウィンドウの mtime 範囲(epoch 秒)。"""
|
|
86
|
+
end = now.timestamp()
|
|
87
|
+
start = (now - timedelta(hours=24)).timestamp()
|
|
88
|
+
return start, end
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_for_project(
|
|
92
|
+
records: list[FileRecord], project: str, *, today: date, now: datetime
|
|
93
|
+
) -> ProjectBrief:
|
|
94
|
+
"""1 プロジェクト分のレコードから ProjectBrief を組み立てる。"""
|
|
95
|
+
open_recs = [r for r in records if _is_open_state(r)]
|
|
96
|
+
scored: list[tuple[FileRecord, ScoreBreakdown]] = sorted(
|
|
97
|
+
((r, score_record(r, today=today)) for r in open_recs),
|
|
98
|
+
key=lambda pair: (-pair[1].total, tiebreak_key(pair[0])),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
today_pick: dict | None = None
|
|
102
|
+
co_running: list[dict] = []
|
|
103
|
+
if scored:
|
|
104
|
+
head_rec, head_score = scored[0]
|
|
105
|
+
today_pick = _short_record(head_rec, head_score)
|
|
106
|
+
for rec, sc in scored[1:4]:
|
|
107
|
+
if rec.state == "in-progress" or len(co_running) < 2:
|
|
108
|
+
co_running.append(_short_record(rec, sc))
|
|
109
|
+
if len(co_running) >= 3:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
watchouts: list[dict] = []
|
|
113
|
+
for rec in open_recs:
|
|
114
|
+
flags = set(rec.flags or [])
|
|
115
|
+
if not (flags & {
|
|
116
|
+
Flag.NEEDS_DECISION.value,
|
|
117
|
+
Flag.OVERDUE_TODO.value,
|
|
118
|
+
Flag.OVERDUE_GRADUATE.value,
|
|
119
|
+
}):
|
|
120
|
+
continue
|
|
121
|
+
if today_pick and rec.path == today_pick["path"]:
|
|
122
|
+
continue
|
|
123
|
+
watchouts.append(_short_record(rec, score_record(rec, today=today)))
|
|
124
|
+
watchouts.sort(key=lambda d: -(d.get("score") or {}).get("total", 0.0))
|
|
125
|
+
watchouts = watchouts[:5]
|
|
126
|
+
|
|
127
|
+
start, end = _yesterday_window(now)
|
|
128
|
+
yesterday: list[dict] = []
|
|
129
|
+
for rec in records:
|
|
130
|
+
if rec.state not in {"done", "discarded"}:
|
|
131
|
+
continue
|
|
132
|
+
if rec.mtime and start <= rec.mtime <= end:
|
|
133
|
+
yesterday.append(_short_record(rec))
|
|
134
|
+
yesterday.sort(key=lambda d: -(d.get("age_days") or 0))
|
|
135
|
+
yesterday = yesterday[:5]
|
|
136
|
+
|
|
137
|
+
stale_count = sum(1 for r in open_recs if Flag.STALE.value in (r.flags or []))
|
|
138
|
+
|
|
139
|
+
return ProjectBrief(
|
|
140
|
+
project=project,
|
|
141
|
+
today_pick=today_pick,
|
|
142
|
+
co_running=co_running,
|
|
143
|
+
watchouts=watchouts,
|
|
144
|
+
yesterday_done=yesterday,
|
|
145
|
+
open_count=len(open_recs),
|
|
146
|
+
stale_count=stale_count,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _resolve_target_projects(
|
|
151
|
+
records: list[FileRecord],
|
|
152
|
+
*,
|
|
153
|
+
project: str | None,
|
|
154
|
+
all_projects: bool,
|
|
155
|
+
cwd_project: str | None,
|
|
156
|
+
) -> list[str]:
|
|
157
|
+
if all_projects:
|
|
158
|
+
names = sorted({r.project for r in records if r.project})
|
|
159
|
+
return names
|
|
160
|
+
if project:
|
|
161
|
+
return [project]
|
|
162
|
+
if cwd_project:
|
|
163
|
+
return [cwd_project]
|
|
164
|
+
# cwd 解決ができない時は records から先頭プロジェクトを 1 つ
|
|
165
|
+
names = sorted({r.project for r in records if r.project})
|
|
166
|
+
return names[:1]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _detect_cwd_project(config: Config) -> str | None:
|
|
170
|
+
"""現在ディレクトリが含まれるプロジェクトを推測する(``cwd プロジェクト`` 既定)。
|
|
171
|
+
|
|
172
|
+
まず ``config.roots`` 配下に cwd が含まれていれば、その root の name を返す。
|
|
173
|
+
git remote が使える場合はそちら優先。失敗時は None(呼び出し側で他のフォールバック)。
|
|
174
|
+
"""
|
|
175
|
+
import os
|
|
176
|
+
import subprocess
|
|
177
|
+
|
|
178
|
+
cwd = Path(os.getcwd()).resolve()
|
|
179
|
+
try:
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
["git", "-C", str(cwd), "remote", "get-url", "origin"],
|
|
182
|
+
capture_output=True, text=True, timeout=2,
|
|
183
|
+
)
|
|
184
|
+
if result.returncode == 0:
|
|
185
|
+
url = result.stdout.strip()
|
|
186
|
+
if url:
|
|
187
|
+
tail = url.rstrip("/").split("/")[-1]
|
|
188
|
+
if tail.endswith(".git"):
|
|
189
|
+
tail = tail[:-4]
|
|
190
|
+
if tail:
|
|
191
|
+
return tail
|
|
192
|
+
except (OSError, subprocess.SubprocessError):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
for root in config.roots:
|
|
196
|
+
try:
|
|
197
|
+
cwd.relative_to(Path(root).resolve())
|
|
198
|
+
return Path(root).name
|
|
199
|
+
except ValueError:
|
|
200
|
+
continue
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_brief(
|
|
205
|
+
config: Config,
|
|
206
|
+
*,
|
|
207
|
+
project: str | None = None,
|
|
208
|
+
all_projects: bool = False,
|
|
209
|
+
today: date | None = None,
|
|
210
|
+
) -> BriefResult:
|
|
211
|
+
"""brief を 1 回ぶん組み立てて返す。
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
config: ロード済み Config
|
|
215
|
+
project: 単一プロジェクト指定(``project_id`` 文字列)
|
|
216
|
+
all_projects: True で search_paths の全プロジェクトを横並び要約
|
|
217
|
+
today: テスト用の日付固定。未指定なら ``date.today()``
|
|
218
|
+
"""
|
|
219
|
+
now = datetime.now(timezone.utc).astimezone()
|
|
220
|
+
today_date = today or now.date()
|
|
221
|
+
|
|
222
|
+
records = scan_records(config)
|
|
223
|
+
|
|
224
|
+
cwd_proj = _detect_cwd_project(config) if not (project or all_projects) else None
|
|
225
|
+
targets = _resolve_target_projects(
|
|
226
|
+
records, project=project, all_projects=all_projects, cwd_project=cwd_proj,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
by_project: dict[str, list[FileRecord]] = {}
|
|
230
|
+
for r in records:
|
|
231
|
+
if r.project:
|
|
232
|
+
by_project.setdefault(r.project, []).append(r)
|
|
233
|
+
|
|
234
|
+
projects: list[ProjectBrief] = []
|
|
235
|
+
for name in targets:
|
|
236
|
+
proj_recs = by_project.get(name, [])
|
|
237
|
+
projects.append(_build_for_project(proj_recs, name, today=today_date, now=now))
|
|
238
|
+
|
|
239
|
+
return BriefResult(
|
|
240
|
+
mode="all" if all_projects else "single",
|
|
241
|
+
generated_at=now.isoformat(),
|
|
242
|
+
projects=projects,
|
|
243
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""C2 (wings): capture — 会話履歴から plan / bugfix / pending の草案を抽出する上流の双子。
|
|
2
|
+
|
|
3
|
+
設計の要点:
|
|
4
|
+
- Heuristic 経路(LLM 無し)と LLM 経路(mock / 実 provider)の 2 系統
|
|
5
|
+
- 草案は :class:`models.Draft` で表現し、ユーザーが番号選択で採用 → frontmatter 付き md を生成
|
|
6
|
+
- 実 LLM API(OpenAI/Anthropic)は本 plan では呼ばない(abstract + Mock のみ)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .models import Draft, DraftKind
|
|
10
|
+
from .service import (
|
|
11
|
+
extract_drafts,
|
|
12
|
+
save_drafts,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = ["Draft", "DraftKind", "extract_drafts", "save_drafts"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""LLM を使わずに会話履歴から草案を拾うヒューリスティック。
|
|
2
|
+
|
|
3
|
+
LLM 経路の前段として、決定事項マーカー(「決定」「TODO」「バグ」等)を含む段落を
|
|
4
|
+
切り出し、最低限の Draft を作る。LLM が無くても CLI / MCP の口は動く。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .llm import _make_draft
|
|
12
|
+
from .models import Draft, DraftKind
|
|
13
|
+
|
|
14
|
+
# 決定事項マーカー(段落単位で拾う)。
|
|
15
|
+
PLAN_MARKERS = ("決定", "やる", "実装する", "TODO", "todo", "次に", "やろう", "対応する")
|
|
16
|
+
BUGFIX_MARKERS = ("バグ", "不具合", "壊れ", "再現", "エラー", "落ちる", "出ない")
|
|
17
|
+
PENDING_MARKERS = ("保留", "あとで", "ペンディング", "棚上げ")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _split_paragraphs(text: str) -> list[str]:
|
|
21
|
+
"""1 行空きで段落を切る。連続改行は 1 つの区切りに丸める。"""
|
|
22
|
+
chunks = re.split(r"\n\s*\n", text)
|
|
23
|
+
return [c.strip() for c in chunks if c.strip()]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _classify_paragraph(para: str) -> str | None:
|
|
27
|
+
if any(m in para for m in BUGFIX_MARKERS):
|
|
28
|
+
return DraftKind.BUGFIX.value
|
|
29
|
+
if any(m in para for m in PENDING_MARKERS):
|
|
30
|
+
return DraftKind.PENDING.value
|
|
31
|
+
if any(m in para for m in PLAN_MARKERS):
|
|
32
|
+
return DraftKind.PLAN.value
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_title(para: str) -> str:
|
|
37
|
+
"""段落の最初の意味行をタイトルにする。記号・改行は削る。"""
|
|
38
|
+
for line in para.splitlines():
|
|
39
|
+
s = line.strip()
|
|
40
|
+
if not s:
|
|
41
|
+
continue
|
|
42
|
+
# H1 / 箇条書き記号を剥がす
|
|
43
|
+
s = re.sub(r"^[#\-*>\s]+", "", s).strip()
|
|
44
|
+
if s:
|
|
45
|
+
return s[:60]
|
|
46
|
+
return para[:60]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extract_drafts_heuristic(
|
|
50
|
+
text: str, *, project: str | None = None, max_drafts: int = 5
|
|
51
|
+
) -> list[Draft]:
|
|
52
|
+
"""LLM 不要のヒューリスティック抽出。"""
|
|
53
|
+
drafts: list[Draft] = []
|
|
54
|
+
for para in _split_paragraphs(text):
|
|
55
|
+
kind = _classify_paragraph(para)
|
|
56
|
+
if kind is None:
|
|
57
|
+
continue
|
|
58
|
+
title = _extract_title(para)
|
|
59
|
+
drafts.append(_make_draft(
|
|
60
|
+
idx=len(drafts) + 1,
|
|
61
|
+
kind=kind,
|
|
62
|
+
title=title,
|
|
63
|
+
body_seed=para,
|
|
64
|
+
source_hint="heuristic",
|
|
65
|
+
project=project,
|
|
66
|
+
))
|
|
67
|
+
if len(drafts) >= max_drafts:
|
|
68
|
+
break
|
|
69
|
+
return drafts
|