init the project

This commit is contained in:
evyzacq
2026-05-25 15:09:42 +08:00
commit 7fc0e7852e
122 changed files with 14557 additions and 0 deletions
@@ -0,0 +1,34 @@
---
name: ir_generation_skill
description: 通过自然语言描述,向JSON文件中添加以IR表示的功能点。
---
# add_ir
## 概述
通过自然语言描述由大语言模型自动转换为标准IR格式并写入到JSON文件或追加到JSON文件中。
## 用法
- **命令行传入**:直接传入功能的文字描述
- **文件传入**:通过 `-f` 参数从文本文件读取描述
- **追加模式**:新生成的功能点追加到已有 `_ir.json` 末尾,不影响已有条目
## 输入要求
- 功能的自然语言描述(命令行参数或 `-f` 文件)
- 目标IR JSON文件路径(`--ir`,默认 `output/ir.json`
## 输出
在目标IR JSON文件中追加新的功能点条目,每个条目包含:
- `function`:功能名称
- `source.section`:所属章节
- `source.location`:位置描述
- `trigger`:触发条件(可选)
- `actions`:执行动作(可选)
## 使用提示
描述中应尽量包含功能的触发条件、执行动作和所属章节信息,以提高转换质量。
@@ -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
@@ -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)