Files

316 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
xiaoguo_quick_scan.py — 小果高频扫描(每15分钟)
限制:每次最多处理 3 篇最新的不达标文章,避免积压。
零成本运行。
"""
import subprocess, sys, json, os, re, random
from pathlib import Path
from datetime import datetime, timezone
RAW = Path("/home/hmo/Obsidian/raw/articles")
WIKI = Path("/home/hmo/Obsidian/wiki")
SYNTHESIS = WIKI / "synthesis"
ENTITIES = WIKI / "entities"
CATEGORIES = WIKI / "categories"
LINKS_FILE = Path("/home/hmo/Obsidian/wechat-article-links.md")
STATE_FILE = Path("/tmp/xiaoguo_scan_state.json")
LESSONS_FILE = Path("/home/hmo/Obsidian/wiki/structured_lessons.md")
MAX_PER_RUN = 3
def call_xiaoguo(prompt: str, max_tokens=200, max_retries=3) -> str:
"""调小果 Ollama,带排队锁和重试。
锁文件 /tmp/xiaoguo_busy.lock 防止多源并发冲突。
每次请求最多等 30 秒获取锁,拿到后 120 秒超时。
"""
import time, os, fcntl
lock_path = "/tmp/xiaoguo_busy.lock"
for attempt in range(max_retries):
# 尝试获取锁
lock_acquired = False
try:
with open(lock_path, "w") as lf:
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_acquired = True
# 释放锁,让它随 with 块结束自动释放
except (IOError, OSError):
lock_acquired = False
if not lock_acquired:
if attempt < max_retries - 1:
time.sleep(5 * (attempt + 1)) # 5s, 10s, 15s 退避
continue
return ""
try:
# 真正调小果
payload = {
"model": "Qwen3.6-27B-MTPLX-Optimized-Speed",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
"temperature": 0.1,
}
result = subprocess.run(
["curl", "-s", "--max-time", "120", "-X", "POST",
"http://127.0.0.1:18003/v1/chat/completions",
"-H", "Content-Type: application/json",
"-d", json.dumps(payload)],
capture_output=True, text=True, timeout=130
)
data = json.loads(result.stdout)
return data["choices"][0]["message"]["content"].strip()
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception) as e:
if attempt < max_retries - 1:
time.sleep(3 * (attempt + 1))
continue
return ""
finally:
try:
os.remove(lock_path)
except:
pass
return ""
def get_processed_ids():
if STATE_FILE.exists():
return set(json.loads(STATE_FILE.read_text()).get("processed", []))
return set()
def save_processed_ids(ids):
STATE_FILE.write_text(json.dumps({"processed": list(ids), "updated": datetime.now().isoformat()}))
def check_frontmatter(content: str) -> list:
missing = []
if not content.startswith("---"):
return ["frontmatter"]
end = content.find("---", 3)
fm = content[3:end] if end > 0 else ""
for field in ["original_wechat_url", "wechat_status"]:
if not re.search(rf"^{field}:", fm, re.MULTILINE):
missing.append(field)
return missing
def batch_fix(articles: list) -> int:
"""批量补 frontmatter。"""
if not articles:
return 0
batch_prompt = ""
for fpath, missing in articles:
content = fpath.read_text(encoding="utf-8", errors="replace")
batch_prompt += f"--- {fpath.name} ---\n内容前300字: {content[:300]}\n缺失字段: {missing}\n\n"
prompt = f"""以下每篇文章缺 frontmatter 字段。对每篇输出一行修复建议,格式:「文件名: 字段名=值」。
只输出有把握的,不知道的写「字段名=unknown」。
{batch_prompt}"""
payload = {
"model": "Qwen3.6-27B-MTPLX-Optimized-Speed",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 500,
"temperature": 0.1,
}
try:
result = subprocess.run(
["curl", "-s", "--max-time", "120", "-X", "POST",
"http://127.0.0.1:18003/v1/chat/completions",
"-H", "Content-Type: application/json",
"-d", json.dumps(payload)],
capture_output=True, text=True, timeout=130
)
data = json.loads(result.stdout)
output = data["choices"][0]["message"]["content"].strip()
except Exception as e:
print(f" 小果调用失败: {e}")
return 0
# 解析输出并更新文件
fixed = 0
for fpath, missing in articles:
content_old = fpath.read_text(encoding="utf-8", errors="replace")
lines = content_old.split("\n")
fm_end = 0
for i, line in enumerate(lines):
if i > 0 and line.strip() == "---":
fm_end = i
break
updated = False
for line in output.split("\n"):
if fpath.name in line and "=" in line:
parts = line.split("=", 1)
if len(parts) == 2:
key, val = parts[0].strip().split(":")[-1].strip(), parts[1].strip()
if val and val != "unknown":
# Check if already exists in frontmatter
exists = any(l.startswith(f"{key}:") for l in lines[1:fm_end])
if not exists:
lines.insert(fm_end, f"{key}: {val}")
fm_end += 1
updated = True
if updated:
fpath.write_text("\n".join(lines), encoding="utf-8")
fixed += 1
return fixed
def find_connection_candidates() -> list:
"""找一篇孤立/弱连接的实体,建议它应该连到哪个分类。"""
if not ENTITIES.exists():
return []
orphans = []
all_wiki_content = ""
for f in WIKI.rglob("*.md"):
all_wiki_content += f.read_text(encoding="utf-8", errors="replace")[:500] + "\n"
for f in sorted(ENTITIES.glob("*.md"), key=lambda x: -x.stat().st_mtime)[:5]:
name = f.stem
# 检查被引用次数
refs = all_wiki_content.count(f"[[{name}]]")
if refs <= 1: # 只被自己引用或没人引
content = f.read_text(encoding="utf-8", errors="replace")[:300]
orphans.append((f, name, content, refs))
return orphans
def quality_check_one_synthesis() -> dict:
"""抽查一篇合成报告,检查基本质量。"""
if not SYNTHESIS.exists():
return {}
reports = list(SYNTHESIS.glob("*.md"))
if not reports:
return {}
# 随机选一篇
f = random.choice(reports)
content = f.read_text(encoding="utf-8", errors="replace")
issues = []
# 检查 updated 日期
updated_m = re.search(r"^updated:\s*(\d{4}-\d{2}-\d{2})", content, re.MULTILINE)
if updated_m:
updated = datetime.strptime(updated_m.group(1), "%Y-%m-%d")
days_old = (datetime.now() - updated).days
if days_old > 30:
issues.append(f"已 {days_old} 天未更新")
# 检查 frontmatter
if not content.startswith("---"):
issues.append("缺 frontmatter")
# 检查长度
if len(content) < 500:
issues.append("内容过少")
# 检查 cross-links
if "cross:" not in content[:500]:
issues.append("缺 cross-links")
return {"file": f.name, "issues": issues, "days_old": days_old if updated_m else 0}
def main():
print(f"小果高频扫描 — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print(f" 任务轮次: {datetime.now().timestamp() % 3:.0f}")
# 轮换任务,每次做一类
task_round = int(datetime.now().timestamp() / 900) % 3 # 每900秒(15分)轮换
if task_round == 0:
# Round 0: 补 frontmatter + links(已有逻辑)
processed = get_processed_ids()
needs_fix = []
all_files = sorted(RAW.glob("*.md"), key=lambda f: -f.stat().st_mtime)
for f in all_files:
if f.stem in processed:
continue
content = f.read_text(encoding="utf-8", errors="replace")
missing = check_frontmatter(content)
if missing:
needs_fix.append((f, missing))
if len(needs_fix) >= MAX_PER_RUN:
break
if needs_fix:
print(f" 补 frontmatter: {len(needs_fix)} 篇")
fixed = batch_fix(needs_fix)
new_processed = set(processed)
for f, _ in needs_fix:
new_processed.add(f.stem)
save_processed_ids(new_processed)
# 补 links
linked = 0
for f, _ in needs_fix:
if LINKS_FILE.exists():
lc = LINKS_FILE.read_text(encoding="utf-8")
if f.stem not in lc:
with open(LINKS_FILE, "a", encoding="utf-8") as lf:
lf.write(f"{datetime.now().strftime('%Y-%m-%d')} | {f.stem} | (小果补充) | (待补URL\n")
linked += 1
print(f" 补 links: {linked}")
else:
print(" 无待补文章,跳至连接挖掘")
# frontmatter 补完了就做连接挖掘
orphans = find_connection_candidates()
if orphans:
f, name, content, refs = orphans[0]
print(f" 孤立实体: {name} (引用{refs}次)")
elif task_round == 1:
# Round 1: 连接挖掘 + 跨链接发现
orphans = find_connection_candidates()
if orphans:
f, name, content, refs = orphans[0]
prompt = f"""实体名称: {name}
实体内容: {content[:300]}
引用次数: {refs}
从 wiki 分类中找出最应该链接到这个实体的分类。
可用分类: {', '.join(sorted([c.stem for c in CATEGORIES.glob('*.md')])[:20])}
只回答分类名,不解释。"""
result = call_xiaoguo(prompt)
if result:
print(f" 🔗 建议 {name}{result.strip()}")
else:
print(f" 无连接建议")
else:
print(" 无孤立实体")
# 质量检查
qc = quality_check_one_synthesis()
if qc and qc.get("issues"):
print(f" 质量: {qc['file']}{', '.join(qc['issues'])}")
elif qc:
print(f" 质量: {qc['file']} — OK")
elif task_round == 2:
# Round 2: 教训学习 — 从 self_remediation.md 读取未处理项
remediation = Path("/home/hmo/Obsidian/self_remediation.md")
if remediation.exists():
content = remediation.read_text(encoding="utf-8")
todo_items = re.findall(r"## (.+?)\n.*?问题: (.+?)\n.*?状态: 待修正", content, re.DOTALL)
if todo_items:
slug, issue = todo_items[0]
print(f" 📋 待修正: {slug}{issue.strip()[:100]}")
else:
print(f" 无待修正项")
else:
print(f" 无待修正项")
if __name__ == "__main__":
main()