Files
kwcode/kaiwu/memory/project_md.py
Val-sss c6197d0316 v0.7.0: P1+P2+搜索重构+UI全面优化
P1: KWCODE.md规则注入、/plan风险评估、Checkpoint快照、DocReader
P2: 模型能力自适应、飞轮通知、价值量化仪表盘
搜索: 四级提取管道、并行搜索+BM25重排、意图感知、ChatExpert门控
UI: spinner动画、结果摘要、静默日志、重影大字Header
新增: kwcode setup-search 一键安装SearXNG
测试: 282/282 PASS (含17个E2E真实模型测试)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:52:59 +08:00

382 lines
13 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.
"""
PROJECT.md memory: project structure info (language, framework, test command, etc).
Spec §7.2: split from KAIWU.md into .kaiwu/PROJECT.md.
"""
import logging
import os
import re
from typing import Optional
from kaiwu.core.context import TaskContext
logger = logging.getLogger(__name__)
PROJECT_MD_TEMPLATE = """# Kaiwu 项目记忆
> 自动维护,请勿手动删除关键字段
## 基础信息
- 语言:{language}
- 框架:{framework}
- 包管理:{pkg_manager}
- 测试命令:{test_cmd}
- 主入口:{entry_point}
- 代码风格:{code_style}
## 已知结构规律
## 注意事项
"""
# Token limits (approximate char counts)
GATE_LIMIT = 500 # 10% of model window
LOCATOR_LIMIT = 750 # 15%
VERIFIER_LIMIT = 250 # 5%
def _kaiwu_dir(project_root: str) -> str:
return os.path.join(project_root, ".kaiwu")
def _md_path(project_root: str) -> str:
return os.path.join(_kaiwu_dir(project_root), "PROJECT.md")
def _ensure_dir(project_root: str):
d = _kaiwu_dir(project_root)
if not os.path.exists(d):
os.makedirs(d, exist_ok=True)
def _extract_section(content: str, header: str) -> str:
"""Extract content between a ## header and the next ## header."""
pattern = re.escape(header) + r"\n(.*?)(?=\n## |\Z)"
match = re.search(pattern, content, re.DOTALL)
if match:
return match.group(1).strip()
return ""
def _replace_section(content: str, header: str, new_body: str) -> str:
"""Replace the body of a ## section, preserving the header."""
pattern = re.escape(header) + r"\n(.*?)(?=\n## |\Z)"
replacement = header + "\n" + new_body + "\n"
new_content, count = re.subn(pattern, replacement, content, count=1, flags=re.DOTALL)
if count == 0:
# Section not found, append
new_content = content.rstrip() + "\n\n" + replacement
return new_content
# ── Detection helpers (reused from kaiwu_md.py) ──
def _detect_language(project_root: str) -> str:
ext_count: dict[str, int] = {}
try:
for root, dirs, files in os.walk(project_root):
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("node_modules", "__pycache__", "venv", ".venv")]
for f in files:
ext = os.path.splitext(f)[1].lower()
if ext in (".py", ".js", ".ts", ".go", ".rs", ".java", ".cpp", ".c"):
ext_count[ext] = ext_count.get(ext, 0) + 1
except Exception:
pass
if not ext_count:
return "未检测"
top_ext = max(ext_count, key=ext_count.get)
lang_map = {
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
".go": "Go", ".rs": "Rust", ".java": "Java", ".cpp": "C++", ".c": "C",
}
return lang_map.get(top_ext, "未检测")
def _detect_framework(project_root: str) -> str:
indicators = {
"requirements.txt": {"fastapi": "FastAPI", "django": "Django", "flask": "Flask"},
"pyproject.toml": {"fastapi": "FastAPI", "django": "Django", "flask": "Flask"},
"package.json": {"react": "React", "vue": "Vue", "next": "Next.js", "express": "Express"},
"go.mod": {"gin": "Gin", "echo": "Echo", "fiber": "Fiber"},
}
for dep_file, frameworks in indicators.items():
fpath = os.path.join(project_root, dep_file)
if os.path.exists(fpath):
try:
with open(fpath, "r", encoding="utf-8") as f:
content = f.read().lower()
for keyword, name in frameworks.items():
if keyword in content:
return name
except Exception:
pass
return "未检测"
def _detect_test_cmd(project_root: str) -> str:
if os.path.exists(os.path.join(project_root, "pytest.ini")) or \
os.path.exists(os.path.join(project_root, "pyproject.toml")):
return "pytest"
if os.path.exists(os.path.join(project_root, "package.json")):
return "npm test"
if os.path.exists(os.path.join(project_root, "go.mod")):
return "go test ./..."
return "未检测"
def _detect_entry(project_root: str) -> str:
candidates = ["main.py", "app.py", "src/main.py", "src/app.py", "index.js", "index.ts", "main.go", "cmd/main.go"]
for c in candidates:
if os.path.exists(os.path.join(project_root, c)):
return c
return "未检测"
def _detect_pkg_manager(project_root: str) -> str:
checks = [
("poetry.lock", "Poetry"),
("Pipfile.lock", "Pipenv"),
("pdm.lock", "PDM"),
("requirements.txt", "pip"),
("yarn.lock", "Yarn"),
("pnpm-lock.yaml", "pnpm"),
("package-lock.json", "npm"),
("go.sum", "Go Modules"),
("Cargo.lock", "Cargo"),
]
for filename, manager in checks:
if os.path.exists(os.path.join(project_root, filename)):
return manager
# Fallback: check pyproject.toml for build system
pyproject = os.path.join(project_root, "pyproject.toml")
if os.path.exists(pyproject):
try:
with open(pyproject, "r", encoding="utf-8") as f:
content = f.read().lower()
if "poetry" in content:
return "Poetry"
if "pdm" in content:
return "PDM"
return "pip"
except Exception:
pass
return "未检测"
def _detect_code_style(project_root: str) -> str:
style_files = {
".flake8": "Flake8",
"setup.cfg": "Flake8",
".pylintrc": "Pylint",
"ruff.toml": "Ruff",
".eslintrc.js": "ESLint",
".eslintrc.json": "ESLint",
".prettierrc": "Prettier",
"biome.json": "Biome",
}
for filename, style in style_files.items():
if os.path.exists(os.path.join(project_root, filename)):
return style
# Check pyproject.toml for tool sections
pyproject = os.path.join(project_root, "pyproject.toml")
if os.path.exists(pyproject):
try:
with open(pyproject, "r", encoding="utf-8") as f:
content = f.read().lower()
if "[tool.ruff]" in content:
return "Ruff"
if "[tool.black]" in content:
return "Black"
if "[tool.flake8]" in content:
return "Flake8"
except Exception:
pass
return "未检测"
# ── Public API ──
def load(project_root: str) -> str:
"""Return full PROJECT.md content as injectable context."""
path = _md_path(project_root)
if not os.path.exists(path):
return ""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
logger.warning("Failed to read PROJECT.md: %s", e)
return ""
def save(project_root: str, ctx: TaskContext):
"""Update PROJECT.md after a successful task."""
path = _md_path(project_root)
if not os.path.exists(path):
# Auto-init on first save
init(project_root)
path = _md_path(project_root)
if not os.path.exists(path):
return
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
logger.warning("Failed to read PROJECT.md for save: %s", e)
return
# Update 已知结构规律 from locator results
if ctx.locator_output:
files = ctx.locator_output.get("relevant_files", [])
funcs = ctx.locator_output.get("relevant_functions", [])
if files or funcs:
existing = _extract_section(content, "## 已知结构规律")
existing_lines = set(existing.split("\n")) if existing else set()
new_lines = []
for f in files[:5]:
line = f"- {f}"
if line not in existing_lines:
new_lines.append(line)
for fn in funcs[:3]:
line = f"- fn: {fn}"
if line not in existing_lines:
new_lines.append(line)
if new_lines:
updated = (existing + "\n" + "\n".join(new_lines)).strip()
# Keep section bounded
lines = updated.split("\n")
if len(lines) > 30:
lines = lines[-30:]
content = _replace_section(content, "## 已知结构规律", "\n".join(lines))
# Update 注意事项 from verifier results
if ctx.verifier_output:
notes_section = _extract_section(content, "## 注意事项")
existing_lines = set(notes_section.split("\n")) if notes_section else set()
new_lines = []
if ctx.verifier_output.get("passed"):
tests_passed = ctx.verifier_output.get("tests_passed", 0)
tests_total = ctx.verifier_output.get("tests_total", 0)
if tests_total > 0:
line = f"- 测试通过 {tests_passed}/{tests_total}"
if line not in existing_lines:
new_lines.append(line)
else:
error = ctx.verifier_output.get("error_detail", "")
if error:
line = f"- 注意: {error[:80]}"
if line not in existing_lines:
new_lines.append(line)
if new_lines:
updated = (notes_section + "\n" + "\n".join(new_lines)).strip()
lines = updated.split("\n")
if len(lines) > 20:
lines = lines[-20:]
content = _replace_section(content, "## 注意事项", "\n".join(lines))
# Update framework detection if still 未检测
content = _update_project_info(content, ctx)
try:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
logger.info("Saved PROJECT.md")
except Exception as e:
logger.warning("Failed to write PROJECT.md: %s", e)
def init(project_root: str) -> str:
"""Auto-detect project info and create PROJECT.md."""
_ensure_dir(project_root)
path = _md_path(project_root)
if os.path.exists(path):
return f"PROJECT.md already exists at {path}"
content = PROJECT_MD_TEMPLATE.format(
language=_detect_language(project_root),
framework=_detect_framework(project_root),
pkg_manager=_detect_pkg_manager(project_root),
test_cmd=_detect_test_cmd(project_root),
entry_point=_detect_entry(project_root),
code_style=_detect_code_style(project_root),
)
try:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return f"Created PROJECT.md at {path}"
except Exception as e:
return f"Failed to create PROJECT.md: {e}"
def show(project_root: str) -> str:
"""Display PROJECT.md content."""
path = _md_path(project_root)
if not os.path.exists(path):
return "PROJECT.md not found. Run `kwcode init` to create."
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
return f"Failed to read PROJECT.md: {e}"
def load_for_gate(project_root: str) -> str:
"""Return 基础信息 section only (token limit ~500 chars)."""
content = load(project_root)
if not content:
return ""
section = _extract_section(content, "## 基础信息")
if not section:
return ""
result = f"项目信息:\n{section}\n"
if len(result) > GATE_LIMIT:
result = result[:GATE_LIMIT] + "\n...(截断)"
return result
def load_for_locator(project_root: str) -> str:
"""Return 已知结构规律 section (token limit ~750 chars)."""
content = load(project_root)
if not content:
return ""
section = _extract_section(content, "## 已知结构规律")
if not section:
return ""
result = f"已知结构规律:\n{section}\n"
if len(result) > LOCATOR_LIMIT:
result = result[:LOCATOR_LIMIT] + "\n...(截断)"
return result
def load_for_verifier(project_root: str) -> str:
"""Return 注意事项 section (token limit ~250 chars)."""
content = load(project_root)
if not content:
return ""
section = _extract_section(content, "## 注意事项")
if not section:
return ""
result = f"注意事项:\n{section}\n"
if len(result) > VERIFIER_LIMIT:
result = result[:VERIFIER_LIMIT] + "\n...(截断)"
return result
def _update_project_info(content: str, ctx: TaskContext) -> str:
"""Update project info if we detected new info from task files."""
files = []
if ctx.locator_output:
files = ctx.locator_output.get("relevant_files", [])
for f in files:
fl = f.lower()
if ("fastapi" in fl or "app.py" in fl) and "框架:未检测" in content:
content = content.replace("框架:未检测", "框架FastAPI")
elif ("django" in fl or "manage.py" in fl) and "框架:未检测" in content:
content = content.replace("框架:未检测", "框架Django")
elif "flask" in fl and "框架:未检测" in content:
content = content.replace("框架:未检测", "框架Flask")
return content