init the project
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Low-level OpenAI-compatible LLM client with retry and token tracking.
|
||||
|
||||
Usage::
|
||||
|
||||
llm = LLMClient()
|
||||
content = llm.chat("qwen3.5-flash", [{"role": "user", "content": "Hello"}])
|
||||
print(llm.usage)
|
||||
"""
|
||||
|
||||
IMAGE_MODEL = "qwen3-vl-plus"
|
||||
TEXT_MODEL = "qwen3.5-flash-2026-02-23"
|
||||
TIMEOUT = 120
|
||||
MAX_RETRIES = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
timeout: int | None = None,
|
||||
):
|
||||
key = os.environ.get("DASHSCOPE_API_KEY", "")
|
||||
if not key:
|
||||
raise ValueError("DASHSCOPE_API_KEY environment variable is not set.")
|
||||
self._client = OpenAI(api_key=key, base_url=base_url)
|
||||
self._timeout = timeout or self.TIMEOUT
|
||||
self._prompt_tokens = 0
|
||||
self._completion_tokens = 0
|
||||
|
||||
@property
|
||||
def usage(self) -> dict:
|
||||
"""Return accumulated token counts as ``{prompt, completion, total}``."""
|
||||
return {
|
||||
"prompt_tokens": self._prompt_tokens,
|
||||
"completion_tokens": self._completion_tokens,
|
||||
"total_tokens": self._prompt_tokens + self._completion_tokens,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Quick token estimate. CJK ≈1.7/token, others ≈3.0/token."""
|
||||
cjk = sum(1 for c in text if '一' <= c <= '鿿' or ' ' <= c <= '〿')
|
||||
other = len(text) - cjk
|
||||
return max(1, int(cjk / 1.7 + other / 3.0))
|
||||
|
||||
@staticmethod
|
||||
def estimate_image_tokens() -> int:
|
||||
"""Fixed estimate for one vision-model image (~500 tokens)."""
|
||||
return 500
|
||||
|
||||
def chat(
|
||||
self, model: str, messages: list[dict], *, timeout: int | None = None,
|
||||
response_format: dict | None = None,
|
||||
) -> str:
|
||||
"""Send a chat completion request and return the response content.
|
||||
|
||||
Automatically retries on failure and accumulates token usage.
|
||||
"""
|
||||
label = f"chat({model})"
|
||||
|
||||
def _call():
|
||||
t0 = time.time()
|
||||
kwargs = dict(model=model, messages=messages, timeout=timeout or self._timeout)
|
||||
if response_format is not None:
|
||||
kwargs["response_format"] = response_format
|
||||
kwargs["temperature"] = 0
|
||||
resp = self._client.chat.completions.create(**kwargs)
|
||||
content = resp.choices[0].message.content
|
||||
usg = resp.usage
|
||||
if usg:
|
||||
self._prompt_tokens += usg.prompt_tokens
|
||||
self._completion_tokens += usg.completion_tokens
|
||||
elapsed = time.time() - t0
|
||||
logger.info("%s: %d chars in %.1fs", label, len(content) if content else 0, elapsed)
|
||||
if not content:
|
||||
raise RuntimeError("Empty response from LLM")
|
||||
return content
|
||||
|
||||
return self._retry(_call, label)
|
||||
|
||||
def _retry(self, fn, label: str) -> str:
|
||||
"""Call *fn()* with exponential-backoff retry."""
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
"%s error (attempt %d/%d): %s",
|
||||
label, attempt + 1, self.MAX_RETRIES, e,
|
||||
)
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
raise RuntimeError(f"{label}: all retries exhausted") from last_error
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add missing features to an existing ``_ir.json``.
|
||||
|
||||
Converts a natural-language description of missing features into structured
|
||||
IR entries via LLM, then appends them to the IR file.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/add_missing.py "遗漏功能描述" --ir output/<basename>_ir.json
|
||||
python scripts/add_missing.py -f missing.txt --ir output/<basename>_ir.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from LLM import LLMClient
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROMPT = """你是一个需求文档分析助手。用户发现已生成的功能点列表中有遗漏,请将以下描述中提到的遗漏功能点转换为标准JSON格式。
|
||||
|
||||
## 遗漏功能描述
|
||||
|
||||
{description}
|
||||
|
||||
## JSON格式要求
|
||||
|
||||
每个功能点输出为:
|
||||
|
||||
{
|
||||
"function": "功能名称",
|
||||
"source": {
|
||||
"section": "章节名(如果描述中提到了章节信息则填写,否则填"未知")",
|
||||
"location": "原文位置描述(如果描述中提供了则填写,否则填"用户补充")"
|
||||
},
|
||||
"trigger": {
|
||||
"type": "AND或者OR",
|
||||
"conditions": [
|
||||
"触发条件1",
|
||||
"触发条件2"
|
||||
]
|
||||
},
|
||||
"actions": {
|
||||
"场景/角色": [
|
||||
"动作1",
|
||||
"动作2"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
## 输出原则
|
||||
|
||||
1. 将描述中的每个独立功能点拆分为一个独立的JSON对象
|
||||
2. 如果描述中包含多个功能点,输出一个JSON数组
|
||||
3. 没有 trigger 或 actions 的字段直接**省略**,不要写 null 或空列表/空对象
|
||||
4. 尽量从描述中推断 source.section 和 source.location
|
||||
5. 功能名称应简洁明确,概括该功能的核心行为
|
||||
6. 直接输出纯JSON,不要用 ```json 代码块包裹
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_llm_response(raw: str) -> list | dict | str | None:
|
||||
"""Parse JSON from LLM response, handling markdown code fences."""
|
||||
if raw is None:
|
||||
return None
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("```"):
|
||||
nl = stripped.find("\n")
|
||||
stripped = stripped[nl + 1:] if nl != -1 else stripped[3:]
|
||||
if stripped.endswith("```"):
|
||||
stripped = stripped[:-3]
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse JSON, returning raw text")
|
||||
return raw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_missing(description: str, ir_path: str) -> list[dict]:
|
||||
"""Convert missing feature description to IR entries and append to IR file.
|
||||
|
||||
Returns the newly added entries.
|
||||
"""
|
||||
llm = LLMClient()
|
||||
|
||||
prompt = PROMPT.replace("{description}", description)
|
||||
|
||||
logger.info("Sending missing features to LLM...")
|
||||
try:
|
||||
raw = llm.chat(
|
||||
model=LLMClient.TEXT_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
logger.info("Response: %d chars", len(raw))
|
||||
except RuntimeError as e:
|
||||
logger.error("LLM call failed: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
parsed = _parse_llm_response(raw)
|
||||
if isinstance(parsed, list):
|
||||
new_entries = parsed
|
||||
elif isinstance(parsed, dict):
|
||||
new_entries = [parsed]
|
||||
else:
|
||||
logger.error("Unparseable response")
|
||||
sys.exit(1)
|
||||
|
||||
# Load existing IR or start fresh
|
||||
if os.path.exists(ir_path):
|
||||
with open(ir_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
logger.info("Loaded existing IR: %d entries", len(existing))
|
||||
else:
|
||||
existing = []
|
||||
logger.info("No existing IR file, creating new")
|
||||
|
||||
existing.extend(new_entries)
|
||||
|
||||
# Write back
|
||||
os.makedirs(os.path.dirname(ir_path) or ".", exist_ok=True)
|
||||
with open(ir_path, "w", encoding="utf-8") as f:
|
||||
json.dump(existing, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info("Saved: %s (%d entries, +%d new)", ir_path, len(existing), len(new_entries))
|
||||
|
||||
usg = llm.usage
|
||||
logger.info("Tokens: %d prompt + %d completion = %d total",
|
||||
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
|
||||
|
||||
return new_entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add missing features to an IR JSON file via LLM.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"description", nargs="?", metavar="description",
|
||||
help="Natural-language description of missing features",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f", "--file", metavar="file.txt",
|
||||
help="Read description from a file instead of command line",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ir", default="output/ir.json", metavar="ir.json",
|
||||
help="Path to the IR JSON file (default: output/ir.json)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.file:
|
||||
with open(args.file, "r", encoding="utf-8") as f:
|
||||
description = f.read().strip()
|
||||
elif args.description:
|
||||
description = args.description
|
||||
else:
|
||||
parser.error("Either provide a description string or use -f to read from a file")
|
||||
|
||||
if not description:
|
||||
parser.error("Description is empty")
|
||||
|
||||
add_missing(description, args.ir)
|
||||
Reference in New Issue
Block a user