From 0df03d55b6192b3a13d3c1f3d0c1b206c03704b9 Mon Sep 17 00:00:00 2001 From: lao-li-said Date: Mon, 15 Jun 2026 05:52:25 +0800 Subject: [PATCH 01/29] =?UTF-8?q?feat(moark-smart-accounting):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=B0=8F=E5=BE=AE=E4=BC=81=E4=B8=9A=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E8=AE=B0=E8=B4=A6=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/moark-smart-accounting/SKILL.md | 93 +++++++ .../scripts/perform_smart_accounting.py | 257 ++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100755 skills/moark-smart-accounting/SKILL.md create mode 100755 skills/moark-smart-accounting/scripts/perform_smart_accounting.py diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md new file mode 100755 index 0000000..2be44d6 --- /dev/null +++ b/skills/moark-smart-accounting/SKILL.md @@ -0,0 +1,93 @@ +--- +name: moark-smart-accounting +description: 口语记账自动分类持久化;将口语经营流水拆分为多笔、匹配会计科目、区分盈亏与往来账;支持餐饮/零售/电商/服务工作室行业 +metadata: + { + "openclaw": + { + "emoji":"🧮", + "requires": { "env": ["GITEEAI_API_KEY"]}, + "primaryEnv": "GITEEAI_API_KEY" + } + } +--- + +# Smart Accounting 🧮 + +面向小微企业/个体经营者的智能记账工具。说一句话,自动拆分多笔流水、匹配会计科目、区分经营盈亏与往来挂账。 + +**核心理念:** 老板只管说话,记账的事交给AI。不需要学复式记账,不需要背科目表。 + +## Usage + +Ensure you have installed the required dependencies (`pip install requests`). Use the bundled script to record and manage business transactions. + +```bash +# Record a single transaction from oral input +python {baseDir}/scripts/perform_smart_accounting.py \ + --input "今天卖了8000,还有进货花了3000,另外给房东付了2000租金" \ + --industry 零售 \ + --api-key YOUR_API_KEY + +# Record with counterparty (receivable/payable) +python {baseDir}/scripts/perform_smart_accounting.py \ + --input "客户张总还欠我5000货款" \ + --api-key YOUR_API_KEY + +# Query monthly summary +python {baseDir}/scripts/perform_smart_accounting.py \ + --action summary \ + --period month \ + --api-key YOUR_API_KEY + +# Query by category +python {baseDir}/scripts/perform_smart_accounting.py \ + --action query \ + --category "主营业务收入" \ + --period month \ + --api-key YOUR_API_KEY +``` + +## Options + +- `--action` - Action to perform. Options: `record` (parse and save transactions, default), `summary` (show P&L summary), `query` (query by category/label), `update` (modify a record), `delete` (remove a record). +- `--input` - (Required for `record`) Oral text describing one or more transactions. +- `--industry` - Industry type for category matching. Options: `餐饮`, `零售`, `电商`, `服务工作室`. Default: generic mode. +- `--period` - Time period for query/summary. Options: `today`, `week`, `month`, `year`, `all`. Default: `month`. +- `--category` - Filter by accounting category (for `query` action). +- `--label` - Filter by business label: `经营产出`, `经营投入`, `往来挂账-应收`, `往来挂账-应付`, `资金划转`, `初始资金`. +- `--record-id` - Record UUID (for `update`/`delete` actions). +- `--field` - Field to update (for `update` action): `amount`, `category`, `description`, `date`. +- `--value` - New value for the field (for `update` action). +- `--output` - Output format. Options: `json` (structured data, default), `markdown` (human-readable report). +- `--model` - LLM model for parsing. Default: `deepseek-v3`. Available: any Gitee AI serverless model. +- `--api-key` - API key used in the `Authorization: Bearer` header. If omitted, read from `GITEEAI_API_KEY`. + +## Workflow + +1. **Parse Oral Input**: AI analyzes `--input`, splits by conjunctions (和/还有/另外/再加), extracts amount/type/category/date/description for each transaction. + +2. **Classify Business Label**: Each transaction is labeled as: + - `经营产出` (revenue) — sales, service fees → counts toward P&L + - `经营投入` (cost/expense) — purchases, rent, wages → counts toward P&L + - `往来挂账-应收` (receivable) — client owes money → does NOT count toward P&L + - `往来挂账-应付` (payable) — owes supplier → does NOT count toward P&L + - `资金划转` (capital transfer) — owner investment/repayment → does NOT count toward P&L + - `初始资金` (initial balance) → does NOT count toward P&L + +3. **Match Accounting Category**: Based on industry and keywords, map to standard categories (主营业务收入, 主营业务成本, 销售费用, 管理费用, etc.). + +4. **Persist to JSON**: Save structured records to `expense_records.json` with UUID, timestamps, and all metadata. + +5. **Output Report**: Print structured results starting with `ACCOUNTING_RESULT:` prefix, containing parsed transactions, P&L calculation, and any warnings. + +## Notes + +- **P&L Formula**: Net P&L = Σ(经营产出) - Σ(经营投入). Receivables/payables are displayed separately and do NOT affect P&L. +- **Counterparty**: Only required when label contains "往来挂账". Must include customer/supplier name. +- **Date**: Defaults to today if not specified. Format: YYYY-MM-DD. +- **Batch Input**: Sentences with multiple transactions are automatically split; each gets its own UUID. +- **Industry Lexicon**: Different industries have different keyword mappings. Specify `--industry` for more accurate categorization. +- **Response Language**: Output language should match the input text language. +- If `GITEEAI_API_KEY` is missing, the user must provide `--api-key`. +- The script prints `ACCOUNTING_RESULT:` in the output. Always parse that line for structured results. diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py new file mode 100755 index 0000000..05e4382 --- /dev/null +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +Smart Accounting - 小微企业智能记账工具 +调用 Gitee AI API 实现口语记账自动拆分、会计科目匹配、盈亏与往来账区分 +""" + +import argparse +import json +import os +import sys +import uuid +from datetime import datetime, date +import requests +from typing import List, Dict, Optional + +# Gitee AI API endpoints +CHAT_API = "https://ai.gitee.com/v1/chat/completions" + +# Data file +DATA_FILE = "expense_records.json" + + +def get_api_key(args) -> str: + if args.api_key: + return args.api_key + key = os.environ.get("GITEEAI_API_KEY", "") + if not key: + print("ERROR: No API key provided. Use --api-key or set GITEEAI_API_KEY.", file=sys.stderr) + sys.exit(1) + return key + + +def call_llm(messages: List[Dict], api_key: str, model: str = "deepseek-v3") -> str: + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096} + try: + resp = requests.post(CHAT_API, headers=headers, json=payload, timeout=60) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + except Exception as e: + print(f"WARNING: LLM call failed: {e}", file=sys.stderr) + return "" + + +def load_records() -> List[Dict]: + if os.path.exists(DATA_FILE): + with open(DATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + return [] + + +def save_records(records: List[Dict]): + with open(DATA_FILE, "w", encoding="utf-8") as f: + json.dump(records, f, ensure_ascii=False, indent=2) + + +def parse_oral_input(text: str, industry: str, api_key: str, model: str) -> List[Dict]: + industry_note = f"行业为{industry}," if industry else "" + prompt = f"""你是一个专业的小微企业会计助手。请将以下口语记账文本拆分为独立的记账记录。 + +{industry_note}拆分规则: +- 遇到"和"、"还有"、"另外"、"再加"等连接词时,拆分为多条记录 +- 每条记录独立匹配会计科目 + +会计科目体系: +收入类:主营业务收入(销售/卖/货款/营业收入/卖货/零售/批发/订单/回款/服务费)、其他业务收入(租金收入/利息/佣金/代理费)、营业外收入(补贴/赔偿/罚款收入/捐赠) +成本费用类:主营业务成本(进货/成本/拿货/采购货款/货物成本)、销售费用(广告/推广/快递/运费/包装)、管理费用(房租/租金/工资/社保/办公用品/水电)、财务费用(利息/刷卡手续费)、营业外支出(罚款/捐赠/赔偿) +往来类:应收账款(客户欠/客户赊账/欠款未收)、应付账款(欠供应商/欠货款未付/赊账拿货) + +经营标签判断: +- 销售商品/服务收到钱 → 经营产出(计入盈亏) +- 进货/付租金/发工资 → 经营投入(计入盈亏) +- 客户欠款 → 往来挂账-应收(不计入盈亏) +- 欠供应商钱 → 往来挂账-应付(不计入盈亏) +- 股东注资/还款 → 资金划转(不计入盈亏) +- 期初余额 → 初始资金(不计入盈亏) + +口语记账文本: +{text} + +请按以下格式输出JSON数组,每个元素包含: +- type: "income" 或 "expense" +- amount: 数字(元) +- category: 会计科目名称 +- description: 简短描述 +- label: 经营标签(经营产出/经营投入/往来挂账-应收/往来挂账-应付/资金划转/初始资金) +- counterparty: 往来对象名称(仅往来挂账时填写,其他为空字符串) +- date: YYYY-MM-DD格式(未指定则填今天 {date.today().isoformat()}) + +仅输出JSON数组,不要其他内容。""" + + result = call_llm([{"role": "user", "content": prompt}], api_key, model) + try: + start = result.find("[") + end = result.rfind("]") + 1 + if start >= 0 and end > start: + return json.loads(result[start:end]) + except (json.JSONDecodeError, ValueError): + pass + # Fallback: single record + return [{"type": "expense", "amount": 0, "category": "其他支出", "description": text, "label": "经营投入", "counterparty": "", "date": date.today().isoformat()}] + + +def do_record(args, api_key: str): + print("Step 1: 解析口语输入...", file=sys.stderr) + parsed = parse_oral_input(args.input, args.industry or "", api_key, args.model) + print(f" 拆分为 {len(parsed)} 条记录", file=sys.stderr) + + records = load_records() + new_records = [] + for item in parsed: + record = { + "id": str(uuid.uuid4()), + "type": item.get("type", "expense"), + "amount": item.get("amount", 0), + "category": item.get("category", ""), + "description": item.get("description", ""), + "label": item.get("label", "经营投入"), + "counterparty": item.get("counterparty", ""), + "date": item.get("date", date.today().isoformat()), + "created_at": datetime.now().isoformat() + } + new_records.append(record) + records.append(record) + + save_records(records) + print(f"Step 2: 已写入 {len(new_records)} 条记录", file=sys.stderr) + + output = { + "action": "record", + "count": len(new_records), + "records": new_records, + "pnl_note": "净盈亏 = 经营产出合计 - 经营投入合计(往来挂账不参与计算)" + } + + if args.output == "markdown": + lines = ["# 🧮 记账结果", ""] + for r in new_records: + lines.append(f"- **{r['label']}** {r['type'] == 'income' and '收入' or '支出'} ¥{r['amount']} | {r['category']} | {r['description']}") + if r.get("counterparty"): + lines.append(f" 往来对象: {r['counterparty']}") + print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") + else: + print(f"ACCOUNTING_RESULT:{json.dumps(output, ensure_ascii=False, indent=2)}") + + +def do_summary(args, api_key: str): + records = load_records() + if not records: + print("ACCOUNTING_RESULT:{\"error\": \"无记账记录\"}") + return + + pnl_income = sum(r["amount"] for r in records if r.get("label") == "经营产出") + pnl_expense = sum(r["amount"] for r in records if r.get("label") == "经营投入") + receivable = sum(r["amount"] for r in records if r.get("label") == "往来挂账-应收") + payable = sum(r["amount"] for r in records if r.get("label") == "往来挂账-应付") + net_pnl = pnl_income - pnl_expense + + summary = { + "action": "summary", + "period": args.period or "all", + "经营产出合计": pnl_income, + "经营投入合计": pnl_expense, + "净盈亏": net_pnl, + "应收账款余额": receivable, + "应付账款余额": payable, + "总记录数": len(records) + } + + if args.output == "markdown": + lines = ["# 🧮 经营汇总", "", + f"| 指标 | 金额 |", + f"|------|------|", + f"| 经营产出合计 | ¥{pnl_income:,.0f} |", + f"| 经营投入合计 | ¥{pnl_expense:,.0f} |", + f"| **净盈亏** | **¥{net_pnl:,.0f}** |", + f"| 应收账款 | ¥{receivable:,.0f} |", + f"| 应付账款 | ¥{payable:,.0f} |"] + print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") + else: + print(f"ACCOUNTING_RESULT:{json.dumps(summary, ensure_ascii=False, indent=2)}") + + +def do_query(args, api_key: str): + records = load_records() + if args.category: + records = [r for r in records if args.category in r.get("category", "")] + if args.label: + records = [r for r in records if args.label in r.get("label", "")] + + if args.output == "markdown": + lines = ["# 🧮 查询结果", ""] + for r in records: + lines.append(f"- [{r['label']}] {r['type'] == 'income' and '收入' or '支出'} ¥{r['amount']} | {r['category']} | {r['description']} | {r['date']}") + print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") + else: + print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'query', 'count': len(records), 'records': records}, ensure_ascii=False, indent=2)}") + + +def do_update(args, api_key: str): + records = load_records() + for r in records: + if r["id"] == args.record_id: + r[args.field] = args.value + if args.field == "amount": + r[args.field] = float(args.value) + save_records(records) + print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'update', 'record': r}, ensure_ascii=False, indent=2)}") + return + print("ACCOUNTING_RESULT:{\"error\": \"记录未找到\"}") + + +def do_delete(args, api_key: str): + records = load_records() + new_records = [r for r in records if r["id"] != args.record_id] + if len(new_records) == len(records): + print("ACCOUNTING_RESULT:{\"error\": \"记录未找到\"}") + return + save_records(new_records) + print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'delete', 'deleted_id': args.record_id}, ensure_ascii=False)}") + + +def main(): + parser = argparse.ArgumentParser(description="Smart Accounting - 小微企业智能记账") + parser.add_argument("--action", default="record", choices=["record", "summary", "query", "update", "delete"]) + parser.add_argument("--input", default="", help="口语记账文本") + parser.add_argument("--industry", default="", help="行业类型") + parser.add_argument("--period", default="month", help="统计周期") + parser.add_argument("--category", default="", help="查询类别") + parser.add_argument("--label", default="", help="经营标签") + parser.add_argument("--record-id", default="", help="记录ID") + parser.add_argument("--field", default="", help="修改字段") + parser.add_argument("--value", default="", help="修改值") + parser.add_argument("--output", default="json", choices=["json", "markdown"]) + parser.add_argument("--model", default="deepseek-v3", help="LLM模型名称") + parser.add_argument("--api-key", default="", help="Gitee AI API Key") + args = parser.parse_args() + + api_key = get_api_key(args) + + if args.action == "record": + if not args.input: + print("ERROR: --input is required for record action.", file=sys.stderr) + sys.exit(1) + do_record(args, api_key) + elif args.action == "summary": + do_summary(args, api_key) + elif args.action == "query": + do_query(args, api_key) + elif args.action == "update": + do_update(args, api_key) + elif args.action == "delete": + do_delete(args, api_key) + + +if __name__ == "__main__": + main() -- Gitee From 57a9a985fd39aa47ddf06ea26a762278e4c44b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Mon, 15 Jun 2026 13:23:40 +0800 Subject: [PATCH 02/29] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DPR=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E9=98=BB=E6=96=AD=E9=A1=B9=20-=20NameError/=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E7=99=BD=E5=90=8D=E5=8D=95/=E9=87=91=E9=A2=9D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C/API=20key=E8=BF=87=E6=BB=A4/JSON=E5=A4=9A?= =?UTF-8?q?=E7=BA=A7=E5=AE=B9=E9=94=99=20v1.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/moark-smart-accounting/SKILL.md | 2 + .../scripts/perform_smart_accounting.py | 219 +++++++++++++++--- 2 files changed, 192 insertions(+), 29 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index 2be44d6..7c00416 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -91,3 +91,5 @@ python {baseDir}/scripts/perform_smart_accounting.py \ - **Response Language**: Output language should match the input text language. - If `GITEEAI_API_KEY` is missing, the user must provide `--api-key`. - The script prints `ACCOUNTING_RESULT:` in the output. Always parse that line for structured results. + +version: 1.0.1 diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 05e4382..856c4a9 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# -*- coding: utf-8 -*- """ Smart Accounting - 小微企业智能记账工具 调用 Gitee AI API 实现口语记账自动拆分、会计科目匹配、盈亏与往来账区分 @@ -7,18 +8,37 @@ Smart Accounting - 小微企业智能记账工具 import argparse import json import os +import re import sys import uuid from datetime import datetime, date import requests from typing import List, Dict, Optional +__version__ = "1.0.1" + # Gitee AI API endpoints CHAT_API = "https://ai.gitee.com/v1/chat/completions" # Data file DATA_FILE = "expense_records.json" +# Field whitelist for update action +UPDATE_ALLOWED_FIELDS = {"amount", "category", "description", "label", "counterparty", "date"} + +# Default values for record fields +RECORD_DEFAULTS = { + "id": "", + "type": "expense", + "amount": 0.0, + "category": "", + "description": "", + "label": "经营投入", + "counterparty": "", + "date": "", + "created_at": "", +} + def get_api_key(args) -> str: if args.api_key: @@ -30,32 +50,156 @@ def get_api_key(args) -> str: return key +def _sanitize_for_log(message: str, api_key: str) -> str: + """Remove API key from log messages to prevent sensitive info leakage.""" + if api_key and api_key in message: + message = message.replace(api_key, "***REDACTED***") + return message + + +def robust_json_parse(text: str, fallback=None): + """Multi-level JSON parsing with graceful fallback. + + Level 1: Standard JSON parse + Level 2: Extract from markdown code blocks (```json ... ```) + Level 3: Find JSON by bracket matching + Level 4: Try fixing common issues (trailing commas) then re-parse + """ + if not text or not text.strip(): + return fallback + + # Level 1: Standard JSON parse + try: + return json.loads(text) + except (json.JSONDecodeError, ValueError): + pass + + # Level 2: Extract from markdown code blocks + code_block_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL) + if code_block_match: + try: + return json.loads(code_block_match.group(1).strip()) + except (json.JSONDecodeError, ValueError): + pass + + # Level 3: Find JSON by bracket matching + for start_char, end_char in [('[', ']'), ('{', '}')]: + start = text.find(start_char) + if start >= 0: + end = text.rfind(end_char) + 1 + if end > start: + try: + return json.loads(text[start:end]) + except (json.JSONDecodeError, ValueError): + pass + + # Level 4: Fix common issues (trailing commas before } or ]) + cleaned = re.sub(r',\s*([}\]])', r'\1', text) + for start_char, end_char in [('[', ']'), ('{', '}')]: + start = cleaned.find(start_char) + if start >= 0: + end = cleaned.rfind(end_char) + 1 + if end > start: + try: + return json.loads(cleaned[start:end]) + except (json.JSONDecodeError, ValueError): + pass + + return fallback + + def call_llm(messages: List[Dict], api_key: str, model: str = "deepseek-v3") -> str: headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096} + max_retries = 2 + for attempt in range(max_retries + 1): + try: + resp = requests.post(CHAT_API, headers=headers, json=payload, timeout=60) + resp.raise_for_status() + content = resp.json()["choices"][0]["message"]["content"] + if content and content.strip(): + return content + if attempt < max_retries: + print(f"WARNING: LLM returned empty content, retrying ({attempt + 1}/{max_retries})...", file=sys.stderr) + continue + print("WARNING: LLM returned empty content after retries.", file=sys.stderr) + return "" + except Exception as e: + err_msg = _sanitize_for_log(str(e), api_key) + if attempt < max_retries: + print(f"WARNING: LLM call failed (attempt {attempt + 1}/{max_retries + 1}): {err_msg}", file=sys.stderr) + import time + time.sleep(2) + else: + print(f"WARNING: LLM call failed after {max_retries + 1} attempts: {err_msg}", file=sys.stderr) + return "" + return "" + + +def _validate_amount(value) -> float: + """Force float conversion and validate amount >= 0.""" try: - resp = requests.post(CHAT_API, headers=headers, json=payload, timeout=60) - resp.raise_for_status() - return resp.json()["choices"][0]["message"]["content"] - except Exception as e: - print(f"WARNING: LLM call failed: {e}", file=sys.stderr) - return "" + amount = float(value) + except (ValueError, TypeError): + raise ValueError(f"Invalid amount value: {value!r}, must be a number") + if amount < 0: + raise ValueError(f"Amount must be >= 0, got {amount}") + return amount def load_records() -> List[Dict]: - if os.path.exists(DATA_FILE): + """Load records from data file with integrity validation.""" + if not os.path.exists(DATA_FILE): + return [] + try: with open(DATA_FILE, "r", encoding="utf-8") as f: - return json.load(f) - return [] - + records = json.load(f) + except (json.JSONDecodeError, IOError) as e: + print(f"WARNING: Failed to load records: {e}, starting with empty list", file=sys.stderr) + return [] -def save_records(records: List[Dict]): + # Data integrity: fill missing fields with defaults + validated = [] + for r in records: + fixed = dict(RECORD_DEFAULTS) + fixed.update(r) + # Ensure amount is float + try: + fixed["amount"] = float(fixed["amount"]) + except (ValueError, TypeError): + fixed["amount"] = 0.0 + # Ensure date is not empty + if not fixed["date"]: + fixed["date"] = date.today().isoformat() + # Ensure id exists + if not fixed["id"]: + fixed["id"] = str(uuid.uuid4()) + validated.append(fixed) + return validated + + +def save_records(records: List[Dict], api_key: str = ""): + """Save records to data file, ensuring no API key leakage.""" + # Sanitize: remove any field that looks like an API key + sanitized = [] + for r in records: + clean = {} + for k, v in r.items(): + # Skip any suspicious key names + if any(s in k.lower() for s in ["api_key", "apikey", "api-key", "secret", "token", "password"]): + continue + # Check if value contains API key pattern (sk-xxx or long hex strings in value) + if isinstance(v, str) and api_key and api_key in v: + continue + clean[k] = v + sanitized.append(clean) with open(DATA_FILE, "w", encoding="utf-8") as f: - json.dump(records, f, ensure_ascii=False, indent=2) + json.dump(sanitized, f, ensure_ascii=False, indent=2) def parse_oral_input(text: str, industry: str, api_key: str, model: str) -> List[Dict]: industry_note = f"行业为{industry}," if industry else "" + today_str = date.today().isoformat() prompt = f"""你是一个专业的小微企业会计助手。请将以下口语记账文本拆分为独立的记账记录。 {industry_note}拆分规则: @@ -76,7 +220,9 @@ def parse_oral_input(text: str, industry: str, api_key: str, model: str) -> List - 期初余额 → 初始资金(不计入盈亏) 口语记账文本: +===BEGIN_INPUT=== {text} +===END_INPUT=== 请按以下格式输出JSON数组,每个元素包含: - type: "income" 或 "expense" @@ -85,20 +231,17 @@ def parse_oral_input(text: str, industry: str, api_key: str, model: str) -> List - description: 简短描述 - label: 经营标签(经营产出/经营投入/往来挂账-应收/往来挂账-应付/资金划转/初始资金) - counterparty: 往来对象名称(仅往来挂账时填写,其他为空字符串) -- date: YYYY-MM-DD格式(未指定则填今天 {date.today().isoformat()}) +- date: YYYY-MM-DD格式(未指定则填今天 {today_str}) 仅输出JSON数组,不要其他内容。""" result = call_llm([{"role": "user", "content": prompt}], api_key, model) - try: - start = result.find("[") - end = result.rfind("]") + 1 - if start >= 0 and end > start: - return json.loads(result[start:end]) - except (json.JSONDecodeError, ValueError): - pass - # Fallback: single record - return [{"type": "expense", "amount": 0, "category": "其他支出", "description": text, "label": "经营投入", "counterparty": "", "date": date.today().isoformat()}] + parsed = robust_json_parse(result, fallback=None) + if isinstance(parsed, list) and len(parsed) > 0: + return parsed + # Final fallback: single record + print("WARNING: Failed to parse LLM response as JSON array, using fallback single record", file=sys.stderr) + return [{"type": "expense", "amount": 0, "category": "其他支出", "description": text, "label": "经营投入", "counterparty": "", "date": today_str}] def do_record(args, api_key: str): @@ -109,21 +252,28 @@ def do_record(args, api_key: str): records = load_records() new_records = [] for item in parsed: + # Force float conversion and validation for amount + try: + amount = _validate_amount(item.get("amount", 0)) + except ValueError as e: + print(f"WARNING: {e}, defaulting to 0.0", file=sys.stderr) + amount = 0.0 + record = { "id": str(uuid.uuid4()), "type": item.get("type", "expense"), - "amount": item.get("amount", 0), + "amount": amount, "category": item.get("category", ""), "description": item.get("description", ""), "label": item.get("label", "经营投入"), "counterparty": item.get("counterparty", ""), - "date": item.get("date", date.today().isoformat()), + "date": item.get("date", date.today().isoformat()) or date.today().isoformat(), "created_at": datetime.now().isoformat() } new_records.append(record) records.append(record) - save_records(records) + save_records(records, api_key) print(f"Step 2: 已写入 {len(new_records)} 条记录", file=sys.stderr) output = { @@ -198,13 +348,24 @@ def do_query(args, api_key: str): def do_update(args, api_key: str): + # Field whitelist validation + if args.field not in UPDATE_ALLOWED_FIELDS: + print(f"ACCOUNTING_RESULT:{{\"error\": \"字段 '{args.field}' 不允许修改,允许的字段: {', '.join(sorted(UPDATE_ALLOWED_FIELDS))}\"}}") + return + records = load_records() for r in records: if r["id"] == args.record_id: - r[args.field] = args.value + # Amount validation if args.field == "amount": - r[args.field] = float(args.value) - save_records(records) + try: + r[args.field] = _validate_amount(args.value) + except ValueError as e: + print(f"ACCOUNTING_RESULT:{{\"error\": \"{e}\"}}") + return + else: + r[args.field] = args.value + save_records(records, api_key) print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'update', 'record': r}, ensure_ascii=False, indent=2)}") return print("ACCOUNTING_RESULT:{\"error\": \"记录未找到\"}") @@ -216,7 +377,7 @@ def do_delete(args, api_key: str): if len(new_records) == len(records): print("ACCOUNTING_RESULT:{\"error\": \"记录未找到\"}") return - save_records(new_records) + save_records(new_records, api_key) print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'delete', 'deleted_id': args.record_id}, ensure_ascii=False)}") -- Gitee From d8e95229c212ffb8c0338743fc16fab706338ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 11:39:40 +0000 Subject: [PATCH 03/29] =?UTF-8?q?fix:=20do=5Fsummary/do=5Fquery=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0filter=5Fby=5Fperiod=E5=91=A8=E6=9C=9F=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=EF=BC=8C=E4=BF=AE=E5=A4=8D--period=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=9C=AA=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/perform_smart_accounting.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 856c4a9..9e30567 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -11,7 +11,7 @@ import os import re import sys import uuid -from datetime import datetime, date +from datetime import datetime, date, timedelta import requests from typing import List, Dict, Optional @@ -294,12 +294,34 @@ def do_record(args, api_key: str): print(f"ACCOUNTING_RESULT:{json.dumps(output, ensure_ascii=False, indent=2)}") + +def filter_by_period(records: List[Dict], period: str) -> List[Dict]: + """Filter records by time period.""" + today = date.today() + if period == "today": + target = today.isoformat() + return [r for r in records if r.get("date") == target] + elif period == "week": + week_ago = (today - timedelta(days=7)).isoformat() + return [r for r in records if r.get("date", "") >= week_ago] + elif period == "month": + month_prefix = today.strftime("%Y-%m") + return [r for r in records if r.get("date", "").startswith(month_prefix)] + elif period == "year": + year_prefix = str(today.year) + return [r for r in records if r.get("date", "").startswith(year_prefix)] + return records # "all" or unrecognized -> no filter + + def do_summary(args, api_key: str): records = load_records() if not records: print("ACCOUNTING_RESULT:{\"error\": \"无记账记录\"}") return + # Apply period filter + records = filter_by_period(records, args.period or "month") + pnl_income = sum(r["amount"] for r in records if r.get("label") == "经营产出") pnl_expense = sum(r["amount"] for r in records if r.get("label") == "经营投入") receivable = sum(r["amount"] for r in records if r.get("label") == "往来挂账-应收") @@ -333,6 +355,8 @@ def do_summary(args, api_key: str): def do_query(args, api_key: str): records = load_records() + # Apply period filter + records = filter_by_period(records, args.period or "month") if args.category: records = [r for r in records if args.category in r.get("category", "")] if args.label: -- Gitee From 6beeeecbb2f25c28ce0aed6c2b279d15f819dfa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 15:54:18 +0000 Subject: [PATCH 04/29] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4--api-key?= =?UTF-8?q?=E5=8F=82=E6=95=B0+SKILL.md=E5=90=8C=E6=AD=A5=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/moark-smart-accounting/SKILL.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index 7c00416..e8a9789 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -27,25 +27,21 @@ Ensure you have installed the required dependencies (`pip install requests`). Us python {baseDir}/scripts/perform_smart_accounting.py \ --input "今天卖了8000,还有进货花了3000,另外给房东付了2000租金" \ --industry 零售 \ - --api-key YOUR_API_KEY # Record with counterparty (receivable/payable) python {baseDir}/scripts/perform_smart_accounting.py \ --input "客户张总还欠我5000货款" \ - --api-key YOUR_API_KEY # Query monthly summary python {baseDir}/scripts/perform_smart_accounting.py \ --action summary \ --period month \ - --api-key YOUR_API_KEY # Query by category python {baseDir}/scripts/perform_smart_accounting.py \ --action query \ --category "主营业务收入" \ --period month \ - --api-key YOUR_API_KEY ``` ## Options @@ -61,7 +57,7 @@ python {baseDir}/scripts/perform_smart_accounting.py \ - `--value` - New value for the field (for `update` action). - `--output` - Output format. Options: `json` (structured data, default), `markdown` (human-readable report). - `--model` - LLM model for parsing. Default: `deepseek-v3`. Available: any Gitee AI serverless model. -- `--api-key` - API key used in the `Authorization: Bearer` header. If omitted, read from `GITEEAI_API_KEY`. +- API key is read from `GITEEAI_API_KEY` environment variable (required). ## Workflow @@ -89,7 +85,7 @@ python {baseDir}/scripts/perform_smart_accounting.py \ - **Batch Input**: Sentences with multiple transactions are automatically split; each gets its own UUID. - **Industry Lexicon**: Different industries have different keyword mappings. Specify `--industry` for more accurate categorization. - **Response Language**: Output language should match the input text language. -- If `GITEEAI_API_KEY` is missing, the user must provide `--api-key`. +- API key must be set via `GITEEAI_API_KEY` environment variable. - The script prints `ACCOUNTING_RESULT:` in the output. Always parse that line for structured results. -version: 1.0.1 +version: 1.0.2 -- Gitee From f67ede29af417ab9f15367ae9f2def23e212b0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 15:54:19 +0000 Subject: [PATCH 05/29] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4--api-key+do=5Fu?= =?UTF-8?q?pdate=E5=8A=A0label/date=E5=AD=97=E6=AE=B5=E5=80=BC=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C+do=5Fsummary=E5=8D=95=E6=AC=A1=E9=81=8D=E5=8E=86?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/perform_smart_accounting.py | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 9e30567..597772f 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -26,6 +26,9 @@ DATA_FILE = "expense_records.json" # Field whitelist for update action UPDATE_ALLOWED_FIELDS = {"amount", "category", "description", "label", "counterparty", "date"} +# Valid label values for business accounting +VALID_LABELS = {"经营产出", "经营投入", "往来挂账-应收", "往来挂账-应付", "资金划转", "初始资金"} + # Default values for record fields RECORD_DEFAULTS = { "id": "", @@ -41,11 +44,9 @@ RECORD_DEFAULTS = { def get_api_key(args) -> str: - if args.api_key: - return args.api_key key = os.environ.get("GITEEAI_API_KEY", "") if not key: - print("ERROR: No API key provided. Use --api-key or set GITEEAI_API_KEY.", file=sys.stderr) + print("ERROR: No API key provided. Set GITEEAI_API_KEY environment variable.", file=sys.stderr) sys.exit(1) return key @@ -322,10 +323,22 @@ def do_summary(args, api_key: str): # Apply period filter records = filter_by_period(records, args.period or "month") - pnl_income = sum(r["amount"] for r in records if r.get("label") == "经营产出") - pnl_expense = sum(r["amount"] for r in records if r.get("label") == "经营投入") - receivable = sum(r["amount"] for r in records if r.get("label") == "往来挂账-应收") - payable = sum(r["amount"] for r in records if r.get("label") == "往来挂账-应付") + # Single-pass aggregation (replaces 4 separate list comprehensions) + pnl_income = 0.0 + pnl_expense = 0.0 + receivable = 0.0 + payable = 0.0 + for r in records: + label = r.get("label", "") + amount = r.get("amount", 0.0) + if label == "经营产出": + pnl_income += amount + elif label == "经营投入": + pnl_expense += amount + elif label == "往来挂账-应收": + receivable += amount + elif label == "往来挂账-应付": + payable += amount net_pnl = pnl_income - pnl_expense summary = { @@ -371,12 +384,32 @@ def do_query(args, api_key: str): print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'query', 'count': len(records), 'records': records}, ensure_ascii=False, indent=2)}") +def _validate_date(value: str) -> str: + """Validate date format YYYY-MM-DD.""" + try: + datetime.strptime(value, "%Y-%m-%d") + return value + except ValueError: + raise ValueError(f"Invalid date format: {value!r}, expected YYYY-MM-DD") + + def do_update(args, api_key: str): # Field whitelist validation if args.field not in UPDATE_ALLOWED_FIELDS: print(f"ACCOUNTING_RESULT:{{\"error\": \"字段 '{args.field}' 不允许修改,允许的字段: {', '.join(sorted(UPDATE_ALLOWED_FIELDS))}\"}}") return + # Field value validation + if args.field == "label" and args.value not in VALID_LABELS: + print(f"ACCOUNTING_RESULT:{{\"error\": \"不合法的label值 '{args.value}',合法值: {', '.join(sorted(VALID_LABELS))}\"}}") + return + if args.field == "date": + try: + _validate_date(args.value) + except ValueError as e: + print(f"ACCOUNTING_RESULT:{{\"error\": \"{e}\"}}") + return + records = load_records() for r in records: if r["id"] == args.record_id: @@ -418,7 +451,6 @@ def main(): parser.add_argument("--value", default="", help="修改值") parser.add_argument("--output", default="json", choices=["json", "markdown"]) parser.add_argument("--model", default="deepseek-v3", help="LLM模型名称") - parser.add_argument("--api-key", default="", help="Gitee AI API Key") args = parser.parse_args() api_key = get_api_key(args) -- Gitee From f938a83a883df4e6528c4b0b88271ebd1645ab02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 16:08:10 +0000 Subject: [PATCH 06/29] fix: add requests ImportError protection + bump v1.0.2 --- .../scripts/perform_smart_accounting.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 597772f..238fc1a 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -12,10 +12,14 @@ import re import sys import uuid from datetime import datetime, date, timedelta -import requests +try: + import requests +except ImportError: + print("ERROR: 'requests' library is required. Install with: pip install requests", file=sys.stderr) + sys.exit(1) from typing import List, Dict, Optional -__version__ = "1.0.1" +__version__ = "1.0.2" # Gitee AI API endpoints CHAT_API = "https://ai.gitee.com/v1/chat/completions" -- Gitee From 766943eaa5e3c8a68f56ebb13e32765626e8cb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 18:36:28 +0000 Subject: [PATCH 07/29] fix: reviewer issues - relative path, PEP8 import, data file path --- .../scripts/perform_smart_accounting.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 238fc1a..87da3ae 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -10,6 +10,7 @@ import json import os import re import sys +import time import uuid from datetime import datetime, date, timedelta try: @@ -19,13 +20,17 @@ except ImportError: sys.exit(1) from typing import List, Dict, Optional -__version__ = "1.0.2" +__version__ = "1.0.3" # Gitee AI API endpoints CHAT_API = "https://ai.gitee.com/v1/chat/completions" # Data file -DATA_FILE = "expense_records.json" +from pathlib import Path + +DATA_DIR = Path.home() / ".moark" / "smart-accounting" +DATA_DIR.mkdir(parents=True, exist_ok=True) +DATA_FILE = str(DATA_DIR / "expense_records.json") # Field whitelist for update action UPDATE_ALLOWED_FIELDS = {"amount", "category", "description", "label", "counterparty", "date"} @@ -133,7 +138,6 @@ def call_llm(messages: List[Dict], api_key: str, model: str = "deepseek-v3") -> err_msg = _sanitize_for_log(str(e), api_key) if attempt < max_retries: print(f"WARNING: LLM call failed (attempt {attempt + 1}/{max_retries + 1}): {err_msg}", file=sys.stderr) - import time time.sleep(2) else: print(f"WARNING: LLM call failed after {max_retries + 1} attempts: {err_msg}", file=sys.stderr) -- Gitee From b163e3c1e3ce0ff9b126a9374cbac24d9b02c9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 18:46:44 +0000 Subject: [PATCH 08/29] test v1 --- test_write.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 test_write.md diff --git a/test_write.md b/test_write.md new file mode 100644 index 0000000..af6ad70 --- /dev/null +++ b/test_write.md @@ -0,0 +1 @@ +# test v1 -- Gitee From 8e38cc1ef01f7d1437ff3a59429565adc9ee7505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 18:46:44 +0000 Subject: [PATCH 09/29] test v2 --- test_write.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_write.md b/test_write.md index af6ad70..33fa1c4 100644 --- a/test_write.md +++ b/test_write.md @@ -1 +1 @@ -# test v1 +# test v2 -- Gitee From da130362fd9fca105a813e8077f7e3108880c9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 18:46:46 +0000 Subject: [PATCH 10/29] cleanup --- test_write.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test_write.md diff --git a/test_write.md b/test_write.md deleted file mode 100644 index 33fa1c4..0000000 --- a/test_write.md +++ /dev/null @@ -1 +0,0 @@ -# test v2 -- Gitee From 0227a3499c7ffa44832ec5a51381e99a0c1ecfd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 18:46:58 +0000 Subject: [PATCH 11/29] fix: use if/else ternary instead of and/or pattern --- .../scripts/perform_smart_accounting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 87da3ae..563e5e0 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -295,7 +295,7 @@ def do_record(args, api_key: str): if args.output == "markdown": lines = ["# 🧮 记账结果", ""] for r in new_records: - lines.append(f"- **{r['label']}** {r['type'] == 'income' and '收入' or '支出'} ¥{r['amount']} | {r['category']} | {r['description']}") + lines.append(f"- **{r['label']}** {'收入' if r['type'] == 'income' else '支出'} ¥{r['amount']} | {r['category']} | {r['description']}") if r.get("counterparty"): lines.append(f" 往来对象: {r['counterparty']}") print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") @@ -386,7 +386,7 @@ def do_query(args, api_key: str): if args.output == "markdown": lines = ["# 🧮 查询结果", ""] for r in records: - lines.append(f"- [{r['label']}] {r['type'] == 'income' and '收入' or '支出'} ¥{r['amount']} | {r['category']} | {r['description']} | {r['date']}") + lines.append(f"- [{r['label']}] {'收入' if r['type'] == 'income' else '支出'} ¥{r['amount']} | {r['category']} | {r['description']} | {r['date']}") print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") else: print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'query', 'count': len(records), 'records': records}, ensure_ascii=False, indent=2)}") -- Gitee From cf78ae51a8ac253de905b7f57b47321f9ef90476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 18:47:24 +0000 Subject: [PATCH 12/29] fix: version 1.0.3 + complete --field docs --- skills/moark-smart-accounting/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index e8a9789..eed5a18 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -53,7 +53,7 @@ python {baseDir}/scripts/perform_smart_accounting.py \ - `--category` - Filter by accounting category (for `query` action). - `--label` - Filter by business label: `经营产出`, `经营投入`, `往来挂账-应收`, `往来挂账-应付`, `资金划转`, `初始资金`. - `--record-id` - Record UUID (for `update`/`delete` actions). -- `--field` - Field to update (for `update` action): `amount`, `category`, `description`, `date`. +- `--field` - Field to update (for `update` action): `amount`, `category`, `description`, `label`, `counterparty`, `date`. - `--value` - New value for the field (for `update` action). - `--output` - Output format. Options: `json` (structured data, default), `markdown` (human-readable report). - `--model` - LLM model for parsing. Default: `deepseek-v3`. Available: any Gitee AI serverless model. @@ -88,4 +88,4 @@ python {baseDir}/scripts/perform_smart_accounting.py \ - API key must be set via `GITEEAI_API_KEY` environment variable. - The script prints `ACCOUNTING_RESULT:` in the output. Always parse that line for structured results. -version: 1.0.2 +version: 1.0.3 -- Gitee From 9cd22d73bfd49daf524e552c806a9be8a88ffa3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Tue, 16 Jun 2026 05:27:46 +0000 Subject: [PATCH 13/29] fix: rewrite to follow moark merged skill pattern (openai lib, PEP 723, simplified) --- .../scripts/perform_smart_accounting.py | 671 ++++++------------ 1 file changed, 229 insertions(+), 442 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 563e5e0..15c6b45 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -1,481 +1,268 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "openai" +# ] +# /// + """ -Smart Accounting - 小微企业智能记账工具 -调用 Gitee AI API 实现口语记账自动拆分、会计科目匹配、盈亏与往来账区分 +Smart accounting: parse oral bookkeeping entries, match accounting categories, +and distinguish profit/loss vs.往来 accounts. + +Usage: + python perform_smart_accounting.py --action add --text "lunch 50 yuan" [--api-key KEY] """ import argparse import json import os -import re import sys -import time -import uuid -from datetime import datetime, date, timedelta -try: - import requests -except ImportError: - print("ERROR: 'requests' library is required. Install with: pip install requests", file=sys.stderr) - sys.exit(1) -from typing import List, Dict, Optional - -__version__ = "1.0.3" - -# Gitee AI API endpoints -CHAT_API = "https://ai.gitee.com/v1/chat/completions" - -# Data file +from datetime import datetime from pathlib import Path +from openai import OpenAI DATA_DIR = Path.home() / ".moark" / "smart-accounting" -DATA_DIR.mkdir(parents=True, exist_ok=True) -DATA_FILE = str(DATA_DIR / "expense_records.json") - -# Field whitelist for update action -UPDATE_ALLOWED_FIELDS = {"amount", "category", "description", "label", "counterparty", "date"} - -# Valid label values for business accounting -VALID_LABELS = {"经营产出", "经营投入", "往来挂账-应收", "往来挂账-应付", "资金划转", "初始资金"} - -# Default values for record fields -RECORD_DEFAULTS = { - "id": "", - "type": "expense", - "amount": 0.0, - "category": "", - "description": "", - "label": "经营投入", - "counterparty": "", - "date": "", - "created_at": "", +DATA_FILE = DATA_DIR / "expense_records.json" + +CATEGORY_MAP = { + "餐饮": "catering", + "交通": "transportation", + "住房": "housing", + "购物": "shopping", + "娱乐": "entertainment", + "医疗": "medical", + "教育": "education", + "通讯": "communication", + "工资": "salary", + "投资": "investment", + "其他": "other", } -def get_api_key(args) -> str: - key = os.environ.get("GITEEAI_API_KEY", "") - if not key: - print("ERROR: No API key provided. Set GITEEAI_API_KEY environment variable.", file=sys.stderr) - sys.exit(1) - return key - - -def _sanitize_for_log(message: str, api_key: str) -> str: - """Remove API key from log messages to prevent sensitive info leakage.""" - if api_key and api_key in message: - message = message.replace(api_key, "***REDACTED***") - return message +def get_api_key(provided_key: str | None) -> str | None: + """Get API key from argument or environment.""" + if provided_key: + return provided_key + return os.environ.get("GITEEAI_API_KEY") -def robust_json_parse(text: str, fallback=None): - """Multi-level JSON parsing with graceful fallback. - - Level 1: Standard JSON parse - Level 2: Extract from markdown code blocks (```json ... ```) - Level 3: Find JSON by bracket matching - Level 4: Try fixing common issues (trailing commas) then re-parse - """ - if not text or not text.strip(): - return fallback - - # Level 1: Standard JSON parse - try: - return json.loads(text) - except (json.JSONDecodeError, ValueError): - pass - - # Level 2: Extract from markdown code blocks - code_block_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL) - if code_block_match: - try: - return json.loads(code_block_match.group(1).strip()) - except (json.JSONDecodeError, ValueError): - pass - - # Level 3: Find JSON by bracket matching - for start_char, end_char in [('[', ']'), ('{', '}')]: - start = text.find(start_char) - if start >= 0: - end = text.rfind(end_char) + 1 - if end > start: - try: - return json.loads(text[start:end]) - except (json.JSONDecodeError, ValueError): - pass - - # Level 4: Fix common issues (trailing commas before } or ]) - cleaned = re.sub(r',\s*([}\]])', r'\1', text) - for start_char, end_char in [('[', ']'), ('{', '}')]: - start = cleaned.find(start_char) - if start >= 0: - end = cleaned.rfind(end_char) + 1 - if end > start: - try: - return json.loads(cleaned[start:end]) - except (json.JSONDecodeError, ValueError): - pass - - return fallback - - -def call_llm(messages: List[Dict], api_key: str, model: str = "deepseek-v3") -> str: - headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} - payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096} - max_retries = 2 - for attempt in range(max_retries + 1): - try: - resp = requests.post(CHAT_API, headers=headers, json=payload, timeout=60) - resp.raise_for_status() - content = resp.json()["choices"][0]["message"]["content"] - if content and content.strip(): - return content - if attempt < max_retries: - print(f"WARNING: LLM returned empty content, retrying ({attempt + 1}/{max_retries})...", file=sys.stderr) - continue - print("WARNING: LLM returned empty content after retries.", file=sys.stderr) - return "" - except Exception as e: - err_msg = _sanitize_for_log(str(e), api_key) - if attempt < max_retries: - print(f"WARNING: LLM call failed (attempt {attempt + 1}/{max_retries + 1}): {err_msg}", file=sys.stderr) - time.sleep(2) - else: - print(f"WARNING: LLM call failed after {max_retries + 1} attempts: {err_msg}", file=sys.stderr) - return "" - return "" - - -def _validate_amount(value) -> float: - """Force float conversion and validate amount >= 0.""" +def load_records() -> list[dict]: + """Load expense records from local JSON file.""" + if not DATA_FILE.exists(): + return [] try: - amount = float(value) - except (ValueError, TypeError): - raise ValueError(f"Invalid amount value: {value!r}, must be a number") - if amount < 0: - raise ValueError(f"Amount must be >= 0, got {amount}") - return amount + return json.loads(DATA_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [] -def load_records() -> List[Dict]: - """Load records from data file with integrity validation.""" - if not os.path.exists(DATA_FILE): - return [] +def save_records(records: list[dict]) -> None: + """Save expense records to local JSON file.""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + DATA_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8") + + +def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: + """Use LLM to parse oral bookkeeping text into structured entries.""" + response = client.chat.completions.create( + model=model, + messages=[ + { + "role": "system", + "content": ( + "You are a smart bookkeeping assistant. Parse the user's oral " + "bookkeeping text into structured JSON entries. Each entry must have: " + "type (income/expense/transfer), category (from: 餐饮/交通/住房/购物/娱乐/" + "医疗/教育/通讯/工资/投资/其他), amount (number), description (string), " + "account_type (profit_loss/往来). " + "account_type rules: income from business operations = profit_loss, " + "salary = profit_loss; loans/receivables/payables = 往来. " + "Return a JSON array. If multiple entries in the text, split them." + ), + }, + {"role": "user", "content": f"Parse this bookkeeping entry: {text}"}, + ], + stream=False, + ) + content = response.choices[0].message.content.strip() if response.choices else "[]" + # Extract JSON from response (handle markdown code blocks) + if "```json" in content: + content = content.split("```json")[1].split("```")[0].strip() + elif "```" in content: + content = content.split("```")[1].split("```")[0].strip() try: - with open(DATA_FILE, "r", encoding="utf-8") as f: - records = json.load(f) - except (json.JSONDecodeError, IOError) as e: - print(f"WARNING: Failed to load records: {e}, starting with empty list", file=sys.stderr) + return json.loads(content) + except json.JSONDecodeError: + print(f"Warning: Could not parse LLM response as JSON: {content}", file=sys.stderr) return [] - # Data integrity: fill missing fields with defaults - validated = [] - for r in records: - fixed = dict(RECORD_DEFAULTS) - fixed.update(r) - # Ensure amount is float - try: - fixed["amount"] = float(fixed["amount"]) - except (ValueError, TypeError): - fixed["amount"] = 0.0 - # Ensure date is not empty - if not fixed["date"]: - fixed["date"] = date.today().isoformat() - # Ensure id exists - if not fixed["id"]: - fixed["id"] = str(uuid.uuid4()) - validated.append(fixed) - return validated - - -def save_records(records: List[Dict], api_key: str = ""): - """Save records to data file, ensuring no API key leakage.""" - # Sanitize: remove any field that looks like an API key - sanitized = [] - for r in records: - clean = {} - for k, v in r.items(): - # Skip any suspicious key names - if any(s in k.lower() for s in ["api_key", "apikey", "api-key", "secret", "token", "password"]): - continue - # Check if value contains API key pattern (sk-xxx or long hex strings in value) - if isinstance(v, str) and api_key and api_key in v: - continue - clean[k] = v - sanitized.append(clean) - with open(DATA_FILE, "w", encoding="utf-8") as f: - json.dump(sanitized, f, ensure_ascii=False, indent=2) - - -def parse_oral_input(text: str, industry: str, api_key: str, model: str) -> List[Dict]: - industry_note = f"行业为{industry}," if industry else "" - today_str = date.today().isoformat() - prompt = f"""你是一个专业的小微企业会计助手。请将以下口语记账文本拆分为独立的记账记录。 - -{industry_note}拆分规则: -- 遇到"和"、"还有"、"另外"、"再加"等连接词时,拆分为多条记录 -- 每条记录独立匹配会计科目 - -会计科目体系: -收入类:主营业务收入(销售/卖/货款/营业收入/卖货/零售/批发/订单/回款/服务费)、其他业务收入(租金收入/利息/佣金/代理费)、营业外收入(补贴/赔偿/罚款收入/捐赠) -成本费用类:主营业务成本(进货/成本/拿货/采购货款/货物成本)、销售费用(广告/推广/快递/运费/包装)、管理费用(房租/租金/工资/社保/办公用品/水电)、财务费用(利息/刷卡手续费)、营业外支出(罚款/捐赠/赔偿) -往来类:应收账款(客户欠/客户赊账/欠款未收)、应付账款(欠供应商/欠货款未付/赊账拿货) - -经营标签判断: -- 销售商品/服务收到钱 → 经营产出(计入盈亏) -- 进货/付租金/发工资 → 经营投入(计入盈亏) -- 客户欠款 → 往来挂账-应收(不计入盈亏) -- 欠供应商钱 → 往来挂账-应付(不计入盈亏) -- 股东注资/还款 → 资金划转(不计入盈亏) -- 期初余额 → 初始资金(不计入盈亏) - -口语记账文本: -===BEGIN_INPUT=== -{text} -===END_INPUT=== - -请按以下格式输出JSON数组,每个元素包含: -- type: "income" 或 "expense" -- amount: 数字(元) -- category: 会计科目名称 -- description: 简短描述 -- label: 经营标签(经营产出/经营投入/往来挂账-应收/往来挂账-应付/资金划转/初始资金) -- counterparty: 往来对象名称(仅往来挂账时填写,其他为空字符串) -- date: YYYY-MM-DD格式(未指定则填今天 {today_str}) - -仅输出JSON数组,不要其他内容。""" - - result = call_llm([{"role": "user", "content": prompt}], api_key, model) - parsed = robust_json_parse(result, fallback=None) - if isinstance(parsed, list) and len(parsed) > 0: - return parsed - # Final fallback: single record - print("WARNING: Failed to parse LLM response as JSON array, using fallback single record", file=sys.stderr) - return [{"type": "expense", "amount": 0, "category": "其他支出", "description": text, "label": "经营投入", "counterparty": "", "date": today_str}] - - -def do_record(args, api_key: str): - print("Step 1: 解析口语输入...", file=sys.stderr) - parsed = parse_oral_input(args.input, args.industry or "", api_key, args.model) - print(f" 拆分为 {len(parsed)} 条记录", file=sys.stderr) - records = load_records() - new_records = [] - for item in parsed: - # Force float conversion and validation for amount - try: - amount = _validate_amount(item.get("amount", 0)) - except ValueError as e: - print(f"WARNING: {e}, defaulting to 0.0", file=sys.stderr) - amount = 0.0 - - record = { - "id": str(uuid.uuid4()), - "type": item.get("type", "expense"), - "amount": amount, - "category": item.get("category", ""), - "description": item.get("description", ""), - "label": item.get("label", "经营投入"), - "counterparty": item.get("counterparty", ""), - "date": item.get("date", date.today().isoformat()) or date.today().isoformat(), - "created_at": datetime.now().isoformat() - } - new_records.append(record) - records.append(record) - - save_records(records, api_key) - print(f"Step 2: 已写入 {len(new_records)} 条记录", file=sys.stderr) - - output = { - "action": "record", - "count": len(new_records), - "records": new_records, - "pnl_note": "净盈亏 = 经营产出合计 - 经营投入合计(往来挂账不参与计算)" - } +def query_records(records: list[dict], category: str | None, date: str | None) -> list[dict]: + """Filter records by category and/or date.""" + filtered = records + if category: + filtered = [r for r in filtered if r.get("category") == category] + if date: + filtered = [r for r in filtered if r.get("date", "").startswith(date)] + return filtered - if args.output == "markdown": - lines = ["# 🧮 记账结果", ""] - for r in new_records: - lines.append(f"- **{r['label']}** {'收入' if r['type'] == 'income' else '支出'} ¥{r['amount']} | {r['category']} | {r['description']}") - if r.get("counterparty"): - lines.append(f" 往来对象: {r['counterparty']}") - print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") - else: - print(f"ACCOUNTING_RESULT:{json.dumps(output, ensure_ascii=False, indent=2)}") - - - -def filter_by_period(records: List[Dict], period: str) -> List[Dict]: - """Filter records by time period.""" - today = date.today() - if period == "today": - target = today.isoformat() - return [r for r in records if r.get("date") == target] - elif period == "week": - week_ago = (today - timedelta(days=7)).isoformat() - return [r for r in records if r.get("date", "") >= week_ago] - elif period == "month": - month_prefix = today.strftime("%Y-%m") - return [r for r in records if r.get("date", "").startswith(month_prefix)] - elif period == "year": - year_prefix = str(today.year) - return [r for r in records if r.get("date", "").startswith(year_prefix)] - return records # "all" or unrecognized -> no filter - - -def do_summary(args, api_key: str): - records = load_records() - if not records: - print("ACCOUNTING_RESULT:{\"error\": \"无记账记录\"}") - return - - # Apply period filter - records = filter_by_period(records, args.period or "month") - - # Single-pass aggregation (replaces 4 separate list comprehensions) - pnl_income = 0.0 - pnl_expense = 0.0 - receivable = 0.0 - payable = 0.0 + +def compute_stats(records: list[dict]) -> dict: + """Compute summary statistics from records.""" + total_income = sum(r.get("amount", 0) for r in records if r.get("type") == "income") + total_expense = sum(r.get("amount", 0) for r in records if r.get("type") == "expense") + by_category: dict[str, float] = {} for r in records: - label = r.get("label", "") - amount = r.get("amount", 0.0) - if label == "经营产出": - pnl_income += amount - elif label == "经营投入": - pnl_expense += amount - elif label == "往来挂账-应收": - receivable += amount - elif label == "往来挂账-应付": - payable += amount - net_pnl = pnl_income - pnl_expense - - summary = { - "action": "summary", - "period": args.period or "all", - "经营产出合计": pnl_income, - "经营投入合计": pnl_expense, - "净盈亏": net_pnl, - "应收账款余额": receivable, - "应付账款余额": payable, - "总记录数": len(records) + cat = r.get("category", "其他") + if r.get("type") == "expense": + by_category[cat] = by_category.get(cat, 0) + r.get("amount", 0) + return { + "total_income": total_income, + "total_expense": total_expense, + "net": total_income - total_expense, + "by_category": by_category, + "count": len(records), } - if args.output == "markdown": - lines = ["# 🧮 经营汇总", "", - f"| 指标 | 金额 |", - f"|------|------|", - f"| 经营产出合计 | ¥{pnl_income:,.0f} |", - f"| 经营投入合计 | ¥{pnl_expense:,.0f} |", - f"| **净盈亏** | **¥{net_pnl:,.0f}** |", - f"| 应收账款 | ¥{receivable:,.0f} |", - f"| 应付账款 | ¥{payable:,.0f} |"] - print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") - else: - print(f"ACCOUNTING_RESULT:{json.dumps(summary, ensure_ascii=False, indent=2)}") - - -def do_query(args, api_key: str): + +def main(): + parser = argparse.ArgumentParser( + description="Smart accounting: parse oral bookkeeping entries and manage records" + ) + parser.add_argument( + "--action", "-a", + required=True, + choices=["add", "query", "stats", "update", "delete"], + help="Action to perform: add/query/stats/update/delete", + ) + parser.add_argument( + "--text", "-t", + help="Bookkeeping text to parse (required for add/update)", + ) + parser.add_argument( + "--date", "-d", + help="Date filter (YYYY-MM-DD format) for query/stats", + ) + parser.add_argument( + "--category", "-c", + help="Category filter for query/stats", + ) + parser.add_argument( + "--model", "-m", + default="DeepSeek-R1-0528", + help="Model to use for parsing (default: DeepSeek-R1-0528)", + ) + parser.add_argument( + "--api-key", "-k", + help="Gitee AI API key (overrides GITEEAI_API_KEY env var)", + ) + + args = parser.parse_args() + + # Load existing records records = load_records() - # Apply period filter - records = filter_by_period(records, args.period or "month") - if args.category: - records = [r for r in records if args.category in r.get("category", "")] - if args.label: - records = [r for r in records if args.label in r.get("label", "")] - - if args.output == "markdown": - lines = ["# 🧮 查询结果", ""] - for r in records: - lines.append(f"- [{r['label']}] {'收入' if r['type'] == 'income' else '支出'} ¥{r['amount']} | {r['category']} | {r['description']} | {r['date']}") - print(f"ACCOUNTING_RESULT:{chr(10).join(lines)}") - else: - print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'query', 'count': len(records), 'records': records}, ensure_ascii=False, indent=2)}") - - -def _validate_date(value: str) -> str: - """Validate date format YYYY-MM-DD.""" - try: - datetime.strptime(value, "%Y-%m-%d") - return value - except ValueError: - raise ValueError(f"Invalid date format: {value!r}, expected YYYY-MM-DD") - - -def do_update(args, api_key: str): - # Field whitelist validation - if args.field not in UPDATE_ALLOWED_FIELDS: - print(f"ACCOUNTING_RESULT:{{\"error\": \"字段 '{args.field}' 不允许修改,允许的字段: {', '.join(sorted(UPDATE_ALLOWED_FIELDS))}\"}}") - return - - # Field value validation - if args.field == "label" and args.value not in VALID_LABELS: - print(f"ACCOUNTING_RESULT:{{\"error\": \"不合法的label值 '{args.value}',合法值: {', '.join(sorted(VALID_LABELS))}\"}}") - return - if args.field == "date": - try: - _validate_date(args.value) - except ValueError as e: - print(f"ACCOUNTING_RESULT:{{\"error\": \"{e}\"}}") + + # Actions that don't need API key + if args.action in ("query", "stats", "delete"): + if args.action == "query": + result = query_records(records, args.category, args.date) + print("\nACCOUNTING_RESULT:") + print(json.dumps(result, ensure_ascii=False, indent=2)) return - records = load_records() - for r in records: - if r["id"] == args.record_id: - # Amount validation - if args.field == "amount": - try: - r[args.field] = _validate_amount(args.value) - except ValueError as e: - print(f"ACCOUNTING_RESULT:{{\"error\": \"{e}\"}}") - return - else: - r[args.field] = args.value - save_records(records, api_key) - print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'update', 'record': r}, ensure_ascii=False, indent=2)}") + if args.action == "stats": + filtered = query_records(records, args.category, args.date) + result = compute_stats(filtered) + print("\nACCOUNTING_RESULT:") + print(json.dumps(result, ensure_ascii=False, indent=2)) return - print("ACCOUNTING_RESULT:{\"error\": \"记录未找到\"}") + if args.action == "delete": + # Delete requires --date or index confirmation + if not args.date: + print("Error: --date is required for delete action to specify which records.", file=sys.stderr) + print("Please provide --date YYYY-MM-DD to delete records on that date.", file=sys.stderr) + sys.exit(1) + to_delete = query_records(records, args.category, args.date) + if not to_delete: + print(f"No records found for date={args.date}, category={args.category}.", file=sys.stderr) + sys.exit(0) + print(f"Will delete {len(to_delete)} record(s). Please confirm.", file=sys.stderr) + remaining = [r for r in records if r not in to_delete] + save_records(remaining) + print(f"\nACCOUNTING_RESULT: Deleted {len(to_delete)} record(s). {len(remaining)} remaining.") + return -def do_delete(args, api_key: str): - records = load_records() - new_records = [r for r in records if r["id"] != args.record_id] - if len(new_records) == len(records): - print("ACCOUNTING_RESULT:{\"error\": \"记录未找到\"}") - return - save_records(new_records, api_key) - print(f"ACCOUNTING_RESULT:{json.dumps({'action': 'delete', 'deleted_id': args.record_id}, ensure_ascii=False)}") + # Actions that need API key + api_key = get_api_key(args.api_key) + if not api_key: + print("Error: No API key provided.", file=sys.stderr) + print("Please either:", file=sys.stderr) + print(" 1. Provide --api-key argument", file=sys.stderr) + print(" 2. Set GITEEAI_API_KEY environment variable", file=sys.stderr) + sys.exit(1) + client = OpenAI( + base_url="https://ai.gitee.com/v1", + api_key=api_key, + ) -def main(): - parser = argparse.ArgumentParser(description="Smart Accounting - 小微企业智能记账") - parser.add_argument("--action", default="record", choices=["record", "summary", "query", "update", "delete"]) - parser.add_argument("--input", default="", help="口语记账文本") - parser.add_argument("--industry", default="", help="行业类型") - parser.add_argument("--period", default="month", help="统计周期") - parser.add_argument("--category", default="", help="查询类别") - parser.add_argument("--label", default="", help="经营标签") - parser.add_argument("--record-id", default="", help="记录ID") - parser.add_argument("--field", default="", help="修改字段") - parser.add_argument("--value", default="", help="修改值") - parser.add_argument("--output", default="json", choices=["json", "markdown"]) - parser.add_argument("--model", default="deepseek-v3", help="LLM模型名称") - args = parser.parse_args() + try: + if args.action == "add": + if not args.text: + print("Error: --text is required for add action.", file=sys.stderr) + sys.exit(1) + + print(f"Parsing bookkeeping entry: {args.text}") + entries = parse_entry(client, args.text, args.model) + if not entries: + print("Error: Could not parse the entry.", file=sys.stderr) + sys.exit(1) + + today = args.date or datetime.now().strftime("%Y-%m-%d") + for entry in entries: + entry["date"] = today + entry["id"] = len(records) + 1 + records.append(entry) + + save_records(records) + print(f"\nACCOUNTING_RESULT:") + print(json.dumps(entries, ensure_ascii=False, indent=2)) + + elif args.action == "update": + if not args.text or not args.date: + print("Error: --text and --date are required for update action.", file=sys.stderr) + sys.exit(1) + + print(f"Re-parsing entry for update: {args.text}") + entries = parse_entry(client, args.text, args.model) + if not entries: + print("Error: Could not parse the entry.", file=sys.stderr) + sys.exit(1) + + # Update the first matching record + updated = False + for i, r in enumerate(records): + if r.get("date", "").startswith(args.date): + if args.category and r.get("category") != args.category: + continue + entries[0]["date"] = args.date + entries[0]["id"] = r.get("id", i + 1) + records[i] = entries[0] + updated = True + break + + if updated: + save_records(records) + print(f"\nACCOUNTING_RESULT: Updated record.") + print(json.dumps(entries[0], ensure_ascii=False, indent=2)) + else: + print("No matching record found to update.", file=sys.stderr) - api_key = get_api_key(args) - - if args.action == "record": - if not args.input: - print("ERROR: --input is required for record action.", file=sys.stderr) - sys.exit(1) - do_record(args, api_key) - elif args.action == "summary": - do_summary(args, api_key) - elif args.action == "query": - do_query(args, api_key) - elif args.action == "update": - do_update(args, api_key) - elif args.action == "delete": - do_delete(args, api_key) + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + sys.exit(1) if __name__ == "__main__": -- Gitee From 9a9f0d5b8140014165efb439fb69cf02a45eb18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= <17174545+lao-li-said@user.noreply.gitee.com> Date: Tue, 16 Jun 2026 05:27:47 +0000 Subject: [PATCH 14/29] fix: rewrite to follow moark merged skill pattern (openai lib, PEP 723, simplified) --- skills/moark-smart-accounting/SKILL.md | 102 ++++++++++--------------- 1 file changed, 39 insertions(+), 63 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index eed5a18..b08b832 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -1,91 +1,67 @@ --- name: moark-smart-accounting -description: 口语记账自动分类持久化;将口语经营流水拆分为多笔、匹配会计科目、区分盈亏与往来账;支持餐饮/零售/电商/服务工作室行业 +description: Parse oral bookkeeping entries, match accounting categories, and distinguish profit/loss vs.往来 accounts for business accounting. metadata: { "openclaw": { - "emoji":"🧮", + "emoji":"📊", "requires": { "env": ["GITEEAI_API_KEY"]}, "primaryEnv": "GITEEAI_API_KEY" } } --- -# Smart Accounting 🧮 - -面向小微企业/个体经营者的智能记账工具。说一句话,自动拆分多笔流水、匹配会计科目、区分经营盈亏与往来挂账。 - -**核心理念:** 老板只管说话,记账的事交给AI。不需要学复式记账,不需要背科目表。 +# Smart Accounting +This skill allows users to manage business bookkeeping by parsing oral descriptions into structured entries, matching accounting categories, and distinguishing profit/loss vs.往来 (receivables/payables) accounts. ## Usage -Ensure you have installed the required dependencies (`pip install requests`). Use the bundled script to record and manage business transactions. +Ensure you have installed the required dependencies (`pip install openai`). Use the bundled script for bookkeeping operations. +**Add an entry** ```bash -# Record a single transaction from oral input -python {baseDir}/scripts/perform_smart_accounting.py \ - --input "今天卖了8000,还有进货花了3000,另外给房东付了2000租金" \ - --industry 零售 \ +python {baseDir}/scripts/perform_smart_accounting.py --action add --text "lunch 50 yuan" --api-key YOUR_API_KEY +``` -# Record with counterparty (receivable/payable) -python {baseDir}/scripts/perform_smart_accounting.py \ - --input "客户张总还欠我5000货款" \ +**Query records** +```bash +python {baseDir}/scripts/perform_smart_accounting.py --action query --date 2024-01-01 --category 餐饮 +``` -# Query monthly summary -python {baseDir}/scripts/perform_smart_accounting.py \ - --action summary \ - --period month \ +**View statistics** +```bash +python {baseDir}/scripts/perform_smart_accounting.py --action stats --date 2024-01 +``` -# Query by category -python {baseDir}/scripts/perform_smart_accounting.py \ - --action query \ - --category "主营业务收入" \ - --period month \ +**Update a record** +```bash +python {baseDir}/scripts/perform_smart_accounting.py --action update --text "lunch 60 yuan" --date 2024-01-15 --api-key YOUR_API_KEY ``` -## Options +**Delete records** +```bash +python {baseDir}/scripts/perform_smart_accounting.py --action delete --date 2024-01-15 +``` -- `--action` - Action to perform. Options: `record` (parse and save transactions, default), `summary` (show P&L summary), `query` (query by category/label), `update` (modify a record), `delete` (remove a record). -- `--input` - (Required for `record`) Oral text describing one or more transactions. -- `--industry` - Industry type for category matching. Options: `餐饮`, `零售`, `电商`, `服务工作室`. Default: generic mode. -- `--period` - Time period for query/summary. Options: `today`, `week`, `month`, `year`, `all`. Default: `month`. -- `--category` - Filter by accounting category (for `query` action). -- `--label` - Filter by business label: `经营产出`, `经营投入`, `往来挂账-应收`, `往来挂账-应付`, `资金划转`, `初始资金`. -- `--record-id` - Record UUID (for `update`/`delete` actions). -- `--field` - Field to update (for `update` action): `amount`, `category`, `description`, `label`, `counterparty`, `date`. -- `--value` - New value for the field (for `update` action). -- `--output` - Output format. Options: `json` (structured data, default), `markdown` (human-readable report). -- `--model` - LLM model for parsing. Default: `deepseek-v3`. Available: any Gitee AI serverless model. -- API key is read from `GITEEAI_API_KEY` environment variable (required). +## Options +- `--action` / `-a` (required): Action to perform. Options: `add`, `query`, `stats`, `update`, `delete`. +- `--text` / `-t`: Bookkeeping text to parse (required for add/update). +- `--date` / `-d`: Date filter (YYYY-MM-DD) for query/stats/delete. +- `--category` / `-c`: Category filter for query/stats. +- `--model` / `-m`: Model to use for parsing (default: DeepSeek-R1-0528). +- `--api-key` / `-k`: Gitee AI API key (overrides GITEEAI_API_KEY env var). ## Workflow -1. **Parse Oral Input**: AI analyzes `--input`, splits by conjunctions (和/还有/另外/再加), extracts amount/type/category/date/description for each transaction. - -2. **Classify Business Label**: Each transaction is labeled as: - - `经营产出` (revenue) — sales, service fees → counts toward P&L - - `经营投入` (cost/expense) — purchases, rent, wages → counts toward P&L - - `往来挂账-应收` (receivable) — client owes money → does NOT count toward P&L - - `往来挂账-应付` (payable) — owes supplier → does NOT count toward P&L - - `资金划转` (capital transfer) — owner investment/repayment → does NOT count toward P&L - - `初始资金` (initial balance) → does NOT count toward P&L - -3. **Match Accounting Category**: Based on industry and keywords, map to standard categories (主营业务收入, 主营业务成本, 销售费用, 管理费用, etc.). - -4. **Persist to JSON**: Save structured records to `expense_records.json` with UUID, timestamps, and all metadata. - -5. **Output Report**: Print structured results starting with `ACCOUNTING_RESULT:` prefix, containing parsed transactions, P&L calculation, and any warnings. +1. Execute the perform_smart_accounting.py script with the parameters from the user. +2. Parse the script output and find the line starting with `ACCOUNTING_RESULT:`. +3. Extract the accounting result from that line onwards. +4. Display the result to the user using markdown syntax: `📊[Accounting Result]`. ## Notes - -- **P&L Formula**: Net P&L = Σ(经营产出) - Σ(经营投入). Receivables/payables are displayed separately and do NOT affect P&L. -- **Counterparty**: Only required when label contains "往来挂账". Must include customer/supplier name. -- **Date**: Defaults to today if not specified. Format: YYYY-MM-DD. -- **Batch Input**: Sentences with multiple transactions are automatically split; each gets its own UUID. -- **Industry Lexicon**: Different industries have different keyword mappings. Specify `--industry` for more accurate categorization. -- **Response Language**: Output language should match the input text language. -- API key must be set via `GITEEAI_API_KEY` environment variable. -- The script prints `ACCOUNTING_RESULT:` in the output. Always parse that line for structured results. - -version: 1.0.3 +- If GITEEAI_API_KEY is none, you should remind user to provide --api-key argument (not needed for query/stats/delete without LLM). +- Records are stored locally at `~/.moark/smart-accounting/expense_records.json`. +- Delete action requires --date and will prompt for confirmation. +- The script distinguishes between profit/loss accounts (日常收支) and 往来 accounts (借贷/应收应付). +- The script prints `ACCOUNTING_RESULT:` in the output - extract this result and present it to the user. -- Gitee From abab5e36ad821d20bbcea3a68fd17a1b920537c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 13:36:28 +0800 Subject: [PATCH 15/29] fix: address AI review - delete confirmation, regex JSON extract, remove unused CATEGORY_MAP, YAML format --- skills/moark-smart-accounting/SKILL.md | 13 +++----- .../scripts/perform_smart_accounting.py | 32 +++++++------------ 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index b08b832..8fa3578 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -2,14 +2,11 @@ name: moark-smart-accounting description: Parse oral bookkeeping entries, match accounting categories, and distinguish profit/loss vs.往来 accounts for business accounting. metadata: - { - "openclaw": - { - "emoji":"📊", - "requires": { "env": ["GITEEAI_API_KEY"]}, - "primaryEnv": "GITEEAI_API_KEY" - } - } + openclaw: + emoji: "📊" + requires: + env: ["GITEEAI_API_KEY"] + primaryEnv: "GITEEAI_API_KEY" --- # Smart Accounting diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 15c6b45..06de489 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -17,6 +17,7 @@ Usage: import argparse import json import os +import re import sys from datetime import datetime from pathlib import Path @@ -25,21 +26,6 @@ from openai import OpenAI DATA_DIR = Path.home() / ".moark" / "smart-accounting" DATA_FILE = DATA_DIR / "expense_records.json" -CATEGORY_MAP = { - "餐饮": "catering", - "交通": "transportation", - "住房": "housing", - "购物": "shopping", - "娱乐": "entertainment", - "医疗": "medical", - "教育": "education", - "通讯": "communication", - "工资": "salary", - "投资": "investment", - "其他": "other", -} - - def get_api_key(provided_key: str | None) -> str | None: """Get API key from argument or environment.""" if provided_key: @@ -85,12 +71,12 @@ def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: ], stream=False, ) - content = response.choices[0].message.content.strip() if response.choices else "[]" + raw = response.choices[0].message.content if response.choices and response.choices[0].message else None + content = raw.strip() if raw else "[]" # Extract JSON from response (handle markdown code blocks) - if "```json" in content: - content = content.split("```json")[1].split("```")[0].strip() - elif "```" in content: - content = content.split("```")[1].split("```")[0].strip() + match = re.search(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL) + if match: + content = match.group(1) try: return json.loads(content) except json.JSONDecodeError: @@ -188,7 +174,11 @@ def main(): if not to_delete: print(f"No records found for date={args.date}, category={args.category}.", file=sys.stderr) sys.exit(0) - print(f"Will delete {len(to_delete)} record(s). Please confirm.", file=sys.stderr) + print(f"Will delete {len(to_delete)} record(s). Confirm? [y/N]: ", end="", file=sys.stderr) + confirm = input().strip().lower() + if confirm != "y": + print("Delete cancelled.", file=sys.stderr) + sys.exit(0) remaining = [r for r in records if r not in to_delete] save_records(remaining) print(f"\nACCOUNTING_RESULT: Deleted {len(to_delete)} record(s). {len(remaining)} remaining.") -- Gitee From 1058888926c0fc1febfd255d9b3738bee2435f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 13:53:47 +0800 Subject: [PATCH 16/29] fix: ID generation use max ID, delete protect missing-id records --- .../scripts/perform_smart_accounting.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 06de489..000e368 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -179,7 +179,9 @@ def main(): if confirm != "y": print("Delete cancelled.", file=sys.stderr) sys.exit(0) - remaining = [r for r in records if r not in to_delete] + # Use ID-based deletion to protect records missing id field + to_delete_ids = {r["id"] for r in to_delete if "id" in r} + remaining = [r for r in records if r.get("id") not in to_delete_ids or "id" not in r] save_records(remaining) print(f"\nACCOUNTING_RESULT: Deleted {len(to_delete)} record(s). {len(remaining)} remaining.") return @@ -213,7 +215,7 @@ def main(): today = args.date or datetime.now().strftime("%Y-%m-%d") for entry in entries: entry["date"] = today - entry["id"] = len(records) + 1 + entry["id"] = max((r.get("id", 0) for r in records), default=0) + 1 records.append(entry) save_records(records) -- Gitee From c98aa7ebf54ebfa60493937f8e6aa82f02ba9f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 15:51:10 +0800 Subject: [PATCH 17/29] =?UTF-8?q?feat(smart-accounting):=20Decimal?= =?UTF-8?q?=E9=87=91=E9=A2=9D+=E4=BB=A3=E7=A0=81=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=B1=82+--id=E7=B2=BE=E7=A1=AE=E6=93=8D=E4=BD=9C+=E5=BE=80?= =?UTF-8?q?=E6=9D=A5=E7=BB=9F=E8=AE=A1+--export=20csv+schema=5Fversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLM解析后加validate_entry()代码校验层: type/category/account_type非法值回退默认 - 金额全部用Decimal(str(amount)), 存储为字符串 - stats增加by_account_type字段(应收/应付/借贷往来分类统计) - update/delete改为--id精确操作(兼容--date旧方式) - 数据格式加schema_version: 2.0防格式冲突 - 加--export csv输出选项 - 加next_id()统一ID生成: max(id)+1 - 常量提取: DEFAULT_MODEL, API_BASE_URL, SCHEMA_VERSION, VALID_* --- skills/moark-smart-accounting/SKILL.md | 27 +- .../scripts/perform_smart_accounting.py | 300 ++++++++++++++---- 2 files changed, 255 insertions(+), 72 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index 8fa3578..adbb4a2 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -26,27 +26,34 @@ python {baseDir}/scripts/perform_smart_accounting.py --action add --text "lunch python {baseDir}/scripts/perform_smart_accounting.py --action query --date 2024-01-01 --category 餐饮 ``` -**View statistics** +**View statistics (with 往来 breakdown)** ```bash python {baseDir}/scripts/perform_smart_accounting.py --action stats --date 2024-01 ``` -**Update a record** +**Export as CSV** ```bash -python {baseDir}/scripts/perform_smart_accounting.py --action update --text "lunch 60 yuan" --date 2024-01-15 --api-key YOUR_API_KEY +python {baseDir}/scripts/perform_smart_accounting.py --action query --export csv ``` -**Delete records** +**Update a record by ID (recommended)** ```bash -python {baseDir}/scripts/perform_smart_accounting.py --action delete --date 2024-01-15 +python {baseDir}/scripts/perform_smart_accounting.py --action update --id 3 --text "lunch 60 yuan" --api-key YOUR_API_KEY +``` + +**Delete a record by ID (recommended)** +```bash +python {baseDir}/scripts/perform_smart_accounting.py --action delete --id 3 ``` ## Options - `--action` / `-a` (required): Action to perform. Options: `add`, `query`, `stats`, `update`, `delete`. - `--text` / `-t`: Bookkeeping text to parse (required for add/update). -- `--date` / `-d`: Date filter (YYYY-MM-DD) for query/stats/delete. +- `--id` / `-i`: Record ID for precise update/delete (recommended over --date matching). +- `--date` / `-d`: Date filter (YYYY-MM-DD) for query/stats/update/delete. - `--category` / `-c`: Category filter for query/stats. - `--model` / `-m`: Model to use for parsing (default: DeepSeek-R1-0528). +- `--export`: Export format for results. Options: `json` (default), `csv`. - `--api-key` / `-k`: Gitee AI API key (overrides GITEEAI_API_KEY env var). ## Workflow @@ -58,7 +65,9 @@ python {baseDir}/scripts/perform_smart_accounting.py --action delete --date 2024 ## Notes - If GITEEAI_API_KEY is none, you should remind user to provide --api-key argument (not needed for query/stats/delete without LLM). -- Records are stored locally at `~/.moark/smart-accounting/expense_records.json`. -- Delete action requires --date and will prompt for confirmation. -- The script distinguishes between profit/loss accounts (日常收支) and 往来 accounts (借贷/应收应付). +- Records are stored locally at `~/.moark/smart-accounting/expense_records.json` with schema_version "2.0". +- Use `--id` for precise update/delete. Legacy `--date` matching still supported but may affect wrong records. +- Delete action with `--id` does not prompt for confirmation. With `--date`, it prompts. +- Stats include `by_account_type` field showing income/expense breakdown by profit_loss and 往来. +- All monetary amounts use Decimal precision (stored as strings). - The script prints `ACCOUNTING_RESULT:` in the output - extract this result and present it to the user. diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 000e368..d6a32af 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -12,20 +12,38 @@ and distinguish profit/loss vs.往来 accounts. Usage: python perform_smart_accounting.py --action add --text "lunch 50 yuan" [--api-key KEY] + python perform_smart_accounting.py --action update --id 3 --text "lunch 60 yuan" [--api-key KEY] + python perform_smart_accounting.py --action delete --id 3 + python perform_smart_accounting.py --action stats --export csv """ import argparse +import csv +import io import json import os import re import sys from datetime import datetime +from decimal import Decimal, InvalidOperation from pathlib import Path from openai import OpenAI +DEFAULT_MODEL = "DeepSeek-R1-0528" +API_BASE_URL = "https://ai.gitee.com/v1" +SCHEMA_VERSION = "2.0" + DATA_DIR = Path.home() / ".moark" / "smart-accounting" DATA_FILE = DATA_DIR / "expense_records.json" +VALID_TYPES = {"income", "expense", "transfer"} +VALID_CATEGORIES = { + "餐饮", "交通", "住房", "购物", "娱乐", "医疗", "教育", "通讯", + "工资", "投资", "其他", +} +VALID_ACCOUNT_TYPES = {"profit_loss", "往来"} + + def get_api_key(provided_key: str | None) -> str | None: """Get API key from argument or environment.""" if provided_key: @@ -38,15 +56,60 @@ def load_records() -> list[dict]: if not DATA_FILE.exists(): return [] try: - return json.loads(DATA_FILE.read_text(encoding="utf-8")) + data = json.loads(DATA_FILE.read_text(encoding="utf-8")) + # Handle both raw list and versioned dict + if isinstance(data, dict) and "records" in data: + return data["records"] + if isinstance(data, list): + return data + return [] except (json.JSONDecodeError, OSError): return [] def save_records(records: list[dict]) -> None: - """Save expense records to local JSON file.""" + """Save expense records to local JSON file with schema version.""" DATA_DIR.mkdir(parents=True, exist_ok=True) - DATA_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8") + wrapper = { + "schema_version": SCHEMA_VERSION, + "updated_at": datetime.now().isoformat(), + "records": records, + } + DATA_FILE.write_text(json.dumps(wrapper, ensure_ascii=False, indent=2), encoding="utf-8") + + +def validate_entry(entry: dict) -> dict: + """Validate and fix a parsed entry, falling back to defaults for illegal values.""" + # Validate type + if entry.get("type") not in VALID_TYPES: + entry["type"] = "expense" # default fallback + + # Validate category + if entry.get("category") not in VALID_CATEGORIES: + entry["category"] = "其他" + + # Validate account_type + if entry.get("account_type") not in VALID_ACCOUNT_TYPES: + # Infer from type: transfer defaults to 往来, others to profit_loss + if entry.get("type") == "transfer": + entry["account_type"] = "往来" + else: + entry["account_type"] = "profit_loss" + + # Validate amount with Decimal + try: + amount = Decimal(str(entry.get("amount", 0))) + if amount < 0: + amount = Decimal("0") + entry["amount"] = str(amount) + except (InvalidOperation, ValueError): + entry["amount"] = "0" + + # Ensure description exists + if not entry.get("description"): + entry["description"] = entry.get("category", "其他") + + return entry def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: @@ -73,12 +136,15 @@ def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: ) raw = response.choices[0].message.content if response.choices and response.choices[0].message else None content = raw.strip() if raw else "[]" - # Extract JSON from response (handle markdown code blocks) match = re.search(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL) if match: content = match.group(1) try: - return json.loads(content) + entries = json.loads(content) + if not isinstance(entries, list): + entries = [entries] + # Validate each entry + return [validate_entry(e) for e in entries] except json.JSONDecodeError: print(f"Warning: Could not parse LLM response as JSON: {content}", file=sys.stderr) return [] @@ -95,23 +161,82 @@ def query_records(records: list[dict], category: str | None, date: str | None) - def compute_stats(records: list[dict]) -> dict: - """Compute summary statistics from records.""" - total_income = sum(r.get("amount", 0) for r in records if r.get("type") == "income") - total_expense = sum(r.get("amount", 0) for r in records if r.get("type") == "expense") - by_category: dict[str, float] = {} + """Compute summary statistics from records, including 往来 breakdown.""" + total_income = Decimal("0") + total_expense = Decimal("0") + by_category: dict[str, str] = {} + by_account_type: dict[str, dict[str, str]] = {} + for r in records: + try: + amount = Decimal(str(r.get("amount", 0))) + except (InvalidOperation, ValueError): + amount = Decimal("0") + + r_type = r.get("type", "expense") cat = r.get("category", "其他") - if r.get("type") == "expense": - by_category[cat] = by_category.get(cat, 0) + r.get("amount", 0) + acct = r.get("account_type", "profit_loss") + + if r_type == "income": + total_income += amount + elif r_type == "expense": + total_expense += amount + + # By category (expense only) + if r_type == "expense": + current = Decimal(by_category.get(cat, "0")) + by_category[cat] = str(current + amount) + + # By account_type + if acct not in by_account_type: + by_account_type[acct] = {"income": "0", "expense": "0"} + sub = by_account_type[acct] + if r_type == "income": + sub["income"] = str(Decimal(sub["income"]) + amount) + elif r_type == "expense": + sub["expense"] = str(Decimal(sub["expense"]) + amount) + return { - "total_income": total_income, - "total_expense": total_expense, - "net": total_income - total_expense, + "total_income": str(total_income), + "total_expense": str(total_expense), + "net": str(total_income - total_expense), "by_category": by_category, + "by_account_type": by_account_type, "count": len(records), } +def export_csv(records: list[dict]) -> str: + """Export records to CSV format.""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["id", "date", "type", "category", "amount", "account_type", "description"]) + for r in sorted(records, key=lambda x: x.get("id", 0)): + writer.writerow([ + r.get("id", ""), + r.get("date", ""), + r.get("type", ""), + r.get("category", ""), + r.get("amount", ""), + r.get("account_type", ""), + r.get("description", ""), + ]) + return output.getvalue() + + +def find_record_by_id(records: list[dict], record_id: int) -> dict | None: + """Find a record by its ID.""" + for r in records: + if r.get("id") == record_id: + return r + return None + + +def next_id(records: list[dict]) -> int: + """Generate the next unique ID.""" + return max((r.get("id", 0) for r in records), default=0) + 1 + + def main(): parser = argparse.ArgumentParser( description="Smart accounting: parse oral bookkeeping entries and manage records" @@ -126,6 +251,11 @@ def main(): "--text", "-t", help="Bookkeeping text to parse (required for add/update)", ) + parser.add_argument( + "--id", "-i", + type=int, + help="Record ID for precise update/delete (recommended)", + ) parser.add_argument( "--date", "-d", help="Date filter (YYYY-MM-DD format) for query/stats", @@ -136,8 +266,14 @@ def main(): ) parser.add_argument( "--model", "-m", - default="DeepSeek-R1-0528", - help="Model to use for parsing (default: DeepSeek-R1-0528)", + default=DEFAULT_MODEL, + help=f"Model to use for parsing (default: {DEFAULT_MODEL})", + ) + parser.add_argument( + "--export", + choices=["json", "csv"], + default="json", + help="Export format for query/stats results (default: json)", ) parser.add_argument( "--api-key", "-k", @@ -154,36 +290,50 @@ def main(): if args.action == "query": result = query_records(records, args.category, args.date) print("\nACCOUNTING_RESULT:") - print(json.dumps(result, ensure_ascii=False, indent=2)) + if args.export == "csv": + print(export_csv(result)) + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) return if args.action == "stats": filtered = query_records(records, args.category, args.date) result = compute_stats(filtered) print("\nACCOUNTING_RESULT:") - print(json.dumps(result, ensure_ascii=False, indent=2)) + if args.export == "csv": + print(export_csv(filtered)) + print(f"\nStats: income={result['total_income']}, expense={result['total_expense']}, net={result['net']}") + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) return if args.action == "delete": - # Delete requires --date or index confirmation - if not args.date: - print("Error: --date is required for delete action to specify which records.", file=sys.stderr) - print("Please provide --date YYYY-MM-DD to delete records on that date.", file=sys.stderr) + # Prefer --id for precise deletion, fallback to --date + if args.id is not None: + target = find_record_by_id(records, args.id) + if not target: + print(f"Error: No record found with id={args.id}.", file=sys.stderr) + sys.exit(1) + remaining = [r for r in records if r.get("id") != args.id] + save_records(remaining) + print(f"\nACCOUNTING_RESULT: Deleted record id={args.id}. {len(remaining)} remaining.") + elif args.date: + to_delete = query_records(records, args.category, args.date) + if not to_delete: + print(f"No records found for date={args.date}, category={args.category}.", file=sys.stderr) + sys.exit(0) + print(f"Will delete {len(to_delete)} record(s). Confirm? [y/N]: ", end="", file=sys.stderr) + confirm = input().strip().lower() + if confirm != "y": + print("Delete cancelled.", file=sys.stderr) + sys.exit(0) + to_delete_ids = {r["id"] for r in to_delete if "id" in r} + remaining = [r for r in records if r.get("id") not in to_delete_ids] + save_records(remaining) + print(f"\nACCOUNTING_RESULT: Deleted {len(to_delete)} record(s). {len(remaining)} remaining.") + else: + print("Error: --id or --date is required for delete action.", file=sys.stderr) sys.exit(1) - to_delete = query_records(records, args.category, args.date) - if not to_delete: - print(f"No records found for date={args.date}, category={args.category}.", file=sys.stderr) - sys.exit(0) - print(f"Will delete {len(to_delete)} record(s). Confirm? [y/N]: ", end="", file=sys.stderr) - confirm = input().strip().lower() - if confirm != "y": - print("Delete cancelled.", file=sys.stderr) - sys.exit(0) - # Use ID-based deletion to protect records missing id field - to_delete_ids = {r["id"] for r in to_delete if "id" in r} - remaining = [r for r in records if r.get("id") not in to_delete_ids or "id" not in r] - save_records(remaining) - print(f"\nACCOUNTING_RESULT: Deleted {len(to_delete)} record(s). {len(remaining)} remaining.") return # Actions that need API key @@ -196,7 +346,7 @@ def main(): sys.exit(1) client = OpenAI( - base_url="https://ai.gitee.com/v1", + base_url=API_BASE_URL, api_key=api_key, ) @@ -215,42 +365,66 @@ def main(): today = args.date or datetime.now().strftime("%Y-%m-%d") for entry in entries: entry["date"] = today - entry["id"] = max((r.get("id", 0) for r in records), default=0) + 1 + entry["id"] = next_id(records) records.append(entry) save_records(records) - print(f"\nACCOUNTING_RESULT:") + print("\nACCOUNTING_RESULT:") print(json.dumps(entries, ensure_ascii=False, indent=2)) elif args.action == "update": - if not args.text or not args.date: - print("Error: --text and --date are required for update action.", file=sys.stderr) - sys.exit(1) - - print(f"Re-parsing entry for update: {args.text}") - entries = parse_entry(client, args.text, args.model) - if not entries: - print("Error: Could not parse the entry.", file=sys.stderr) + if not args.text: + print("Error: --text is required for update action.", file=sys.stderr) sys.exit(1) - # Update the first matching record - updated = False - for i, r in enumerate(records): - if r.get("date", "").startswith(args.date): - if args.category and r.get("category") != args.category: - continue - entries[0]["date"] = args.date - entries[0]["id"] = r.get("id", i + 1) - records[i] = entries[0] - updated = True - break - - if updated: + if args.id is not None: + # Precise update by ID + target = find_record_by_id(records, args.id) + if not target: + print(f"Error: No record found with id={args.id}.", file=sys.stderr) + sys.exit(1) + print(f"Re-parsing entry for update (id={args.id}): {args.text}") + entries = parse_entry(client, args.text, args.model) + if not entries: + print("Error: Could not parse the entry.", file=sys.stderr) + sys.exit(1) + new_entry = entries[0] + new_entry["id"] = args.id + new_entry["date"] = args.date or target.get("date", datetime.now().strftime("%Y-%m-%d")) + # Replace in records + for i, r in enumerate(records): + if r.get("id") == args.id: + records[i] = new_entry + break save_records(records) - print(f"\nACCOUNTING_RESULT: Updated record.") - print(json.dumps(entries[0], ensure_ascii=False, indent=2)) + print("\nACCOUNTING_RESULT: Updated record.") + print(json.dumps(new_entry, ensure_ascii=False, indent=2)) + elif args.date: + # Legacy: update first matching record by date + print(f"Re-parsing entry for update (date={args.date}): {args.text}") + entries = parse_entry(client, args.text, args.model) + if not entries: + print("Error: Could not parse the entry.", file=sys.stderr) + sys.exit(1) + updated = False + for i, r in enumerate(records): + if r.get("date", "").startswith(args.date): + if args.category and r.get("category") != args.category: + continue + entries[0]["date"] = args.date + entries[0]["id"] = r.get("id", next_id(records)) + records[i] = entries[0] + updated = True + break + if updated: + save_records(records) + print("\nACCOUNTING_RESULT: Updated record.") + print(json.dumps(entries[0], ensure_ascii=False, indent=2)) + else: + print("No matching record found to update.", file=sys.stderr) else: - print("No matching record found to update.", file=sys.stderr) + print("Error: --id or --date is required for update action.", file=sys.stderr) + sys.exit(1) except Exception as e: print(f"\nError: {e}", file=sys.stderr) -- Gitee From 43b7e4861a88a6b016c3bce62426b487cfb4bcbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 15:58:01 +0800 Subject: [PATCH 18/29] trigger re-review after optimization -- Gitee From db1284f3dacd354f21bcd0f9cfc14339d3d250a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 16:16:25 +0800 Subject: [PATCH 19/29] fix: atomic file write, delete count accuracy, missing-ID warning --- .../scripts/perform_smart_accounting.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index d6a32af..2c98861 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -68,15 +68,21 @@ def load_records() -> list[dict]: def save_records(records: list[dict]) -> None: - """Save expense records to local JSON file with schema version.""" + """Save expense records to local JSON file atomically (temp file + rename).""" + import tempfile DATA_DIR.mkdir(parents=True, exist_ok=True) - wrapper = { - "schema_version": SCHEMA_VERSION, - "updated_at": datetime.now().isoformat(), - "records": records, - } - DATA_FILE.write_text(json.dumps(wrapper, ensure_ascii=False, indent=2), encoding="utf-8") - + content = json.dumps(records, ensure_ascii=False, indent=2) + fd, tmp_path = tempfile.mkstemp(dir=DATA_DIR, suffix='.tmp') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + f.write(content) + os.replace(tmp_path, DATA_FILE) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise def validate_entry(entry: dict) -> dict: """Validate and fix a parsed entry, falling back to defaults for illegal values.""" @@ -328,9 +334,13 @@ def main(): print("Delete cancelled.", file=sys.stderr) sys.exit(0) to_delete_ids = {r["id"] for r in to_delete if "id" in r} + skipped = len(to_delete) - len(to_delete_ids) + if skipped > 0: + print(f"Warning: {skipped} record(s) without ID field skipped.", file=sys.stderr) remaining = [r for r in records if r.get("id") not in to_delete_ids] + actual_count = len(records) - len(remaining) save_records(remaining) - print(f"\nACCOUNTING_RESULT: Deleted {len(to_delete)} record(s). {len(remaining)} remaining.") + print(f"\nACCOUNTING_RESULT: Deleted {actual_count} record(s). {len(remaining)} remaining.") else: print("Error: --id or --date is required for delete action.", file=sys.stderr) sys.exit(1) -- Gitee From 192216ccf9cb42f2d1cc882db717d32be44118ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 16:32:16 +0800 Subject: [PATCH 20/29] fix: backup corrupted JSON + exit instead of silent data loss, move tempfile import, strengthen LLM prompt --- .../scripts/perform_smart_accounting.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 2c98861..dc56ca0 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -26,6 +26,7 @@ import re import sys from datetime import datetime from decimal import Decimal, InvalidOperation +import tempfile from pathlib import Path from openai import OpenAI @@ -63,13 +64,18 @@ def load_records() -> list[dict]: if isinstance(data, list): return data return [] - except (json.JSONDecodeError, OSError): - return [] + except (json.JSONDecodeError, OSError) as e: + backup = DATA_FILE.with_suffix('.json.corrupted') + try: + DATA_FILE.rename(backup) + except OSError: + pass + print(f"Error: Data file corrupted. Backed up to {backup}", file=sys.stderr) + sys.exit(1) def save_records(records: list[dict]) -> None: """Save expense records to local JSON file atomically (temp file + rename).""" - import tempfile DATA_DIR.mkdir(parents=True, exist_ok=True) content = json.dumps(records, ensure_ascii=False, indent=2) fd, tmp_path = tempfile.mkstemp(dir=DATA_DIR, suffix='.tmp') @@ -133,7 +139,8 @@ def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: "account_type (profit_loss/往来). " "account_type rules: income from business operations = profit_loss, " "salary = profit_loss; loans/receivables/payables = 往来. " - "Return a JSON array. If multiple entries in the text, split them." + "Return a JSON array. If multiple entries in the text, split them. " + "IMPORTANT: Return ONLY a valid JSON array. No explanations, no markdown blocks, no extra text." ), }, {"role": "user", "content": f"Parse this bookkeeping entry: {text}"}, -- Gitee From 6483811d7fb3f2c470343505aff48fdfbcd76278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 20:03:44 +0800 Subject: [PATCH 21/29] fix: save_records wraps data with schema_version, add delete confirmation for --id --- .../scripts/perform_smart_accounting.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index dc56ca0..cccb431 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -77,7 +77,11 @@ def load_records() -> list[dict]: def save_records(records: list[dict]) -> None: """Save expense records to local JSON file atomically (temp file + rename).""" DATA_DIR.mkdir(parents=True, exist_ok=True) - content = json.dumps(records, ensure_ascii=False, indent=2) + data = { + "schema_version": SCHEMA_VERSION, + "records": records, + } + content = json.dumps(data, ensure_ascii=False, indent=2) fd, tmp_path = tempfile.mkstemp(dir=DATA_DIR, suffix='.tmp') try: with os.fdopen(fd, 'w', encoding='utf-8') as f: @@ -327,6 +331,11 @@ def main(): if not target: print(f"Error: No record found with id={args.id}.", file=sys.stderr) sys.exit(1) + print(f"Confirm delete record id={args.id}? [y/N]: ", end="", file=sys.stderr) + confirm = input().strip().lower() + if confirm != "y": + print("Delete cancelled.", file=sys.stderr) + sys.exit(0) remaining = [r for r in records if r.get("id") != args.id] save_records(remaining) print(f"\nACCOUNTING_RESULT: Deleted record id={args.id}. {len(remaining)} remaining.") -- Gitee From ce7f59eb238eb67a2b2df154f7bc974a6c93738d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 20:25:48 +0800 Subject: [PATCH 22/29] fix: correct SKILL.md delete confirmation docs; PEP 8 import order; validate_entry docstring; add raw response context to parse errors --- skills/moark-smart-accounting/SKILL.md | 2 +- .../scripts/perform_smart_accounting.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index adbb4a2..e4710de 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -67,7 +67,7 @@ python {baseDir}/scripts/perform_smart_accounting.py --action delete --id 3 - If GITEEAI_API_KEY is none, you should remind user to provide --api-key argument (not needed for query/stats/delete without LLM). - Records are stored locally at `~/.moark/smart-accounting/expense_records.json` with schema_version "2.0". - Use `--id` for precise update/delete. Legacy `--date` matching still supported but may affect wrong records. -- Delete action with `--id` does not prompt for confirmation. With `--date`, it prompts. +- Delete action prompts for confirmation with both `--id` and `--date`. - Stats include `by_account_type` field showing income/expense breakdown by profit_loss and 往来. - All monetary amounts use Decimal precision (stored as strings). - The script prints `ACCOUNTING_RESULT:` in the output - extract this result and present it to the user. diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index cccb431..ead0848 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -17,16 +17,13 @@ Usage: python perform_smart_accounting.py --action stats --export csv """ -import argparse -import csv -import io import json import os import re import sys +import tempfile from datetime import datetime from decimal import Decimal, InvalidOperation -import tempfile from pathlib import Path from openai import OpenAI @@ -95,7 +92,10 @@ def save_records(records: list[dict]) -> None: raise def validate_entry(entry: dict) -> dict: - """Validate and fix a parsed entry, falling back to defaults for illegal values.""" + """Validate and fix a parsed entry, falling back to defaults for illegal values. + + Note: Modifies the entry dictionary in place. + """ # Validate type if entry.get("type") not in VALID_TYPES: entry["type"] = "expense" # default fallback @@ -385,7 +385,7 @@ def main(): print(f"Parsing bookkeeping entry: {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print("Error: Could not parse the entry.", file=sys.stderr) + print(f"Error: Could not parse the entry. Raw response: {content[:200]}", file=sys.stderr) sys.exit(1) today = args.date or datetime.now().strftime("%Y-%m-%d") @@ -412,7 +412,7 @@ def main(): print(f"Re-parsing entry for update (id={args.id}): {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print("Error: Could not parse the entry.", file=sys.stderr) + print(f"Error: Could not parse the entry. Raw response: {content[:200]}", file=sys.stderr) sys.exit(1) new_entry = entries[0] new_entry["id"] = args.id @@ -430,7 +430,7 @@ def main(): print(f"Re-parsing entry for update (date={args.date}): {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print("Error: Could not parse the entry.", file=sys.stderr) + print(f"Error: Could not parse the entry. Raw response: {content[:200]}", file=sys.stderr) sys.exit(1) updated = False for i, r in enumerate(records): -- Gitee From a846bdcd2a2ada9fe7035f23d55ec4ec156e583f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 22:26:24 +0800 Subject: [PATCH 23/29] fix: 3 blocking issues from PR#5 review - Add missing 'import csv' and 'import io' (export_csv was using them but they were not imported) - Remove reference to undefined variable 'content' in error handling - Fix sys.exit(1) indentation error that caused unconditional exit Apply bot review recommendations from latest-reviews.txt --- .../scripts/perform_smart_accounting.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index ead0848..4246c50 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -17,6 +17,8 @@ Usage: python perform_smart_accounting.py --action stats --export csv """ +import csv +import io import json import os import re @@ -385,7 +387,7 @@ def main(): print(f"Parsing bookkeeping entry: {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print(f"Error: Could not parse the entry. Raw response: {content[:200]}", file=sys.stderr) + print("Error: Could not parse the entry. LLM returned invalid or empty response.", file=sys.stderr) sys.exit(1) today = args.date or datetime.now().strftime("%Y-%m-%d") @@ -412,7 +414,7 @@ def main(): print(f"Re-parsing entry for update (id={args.id}): {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print(f"Error: Could not parse the entry. Raw response: {content[:200]}", file=sys.stderr) + print("Error: Could not parse the entry. LLM returned invalid or empty response.", file=sys.stderr) sys.exit(1) new_entry = entries[0] new_entry["id"] = args.id @@ -430,7 +432,7 @@ def main(): print(f"Re-parsing entry for update (date={args.date}): {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print(f"Error: Could not parse the entry. Raw response: {content[:200]}", file=sys.stderr) + print("Error: Could not parse the entry. LLM returned invalid or empty response.", file=sys.stderr) sys.exit(1) updated = False for i, r in enumerate(records): -- Gitee From 2b5a832bc3c364acf5664a53e82c4cb811fe10ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Tue, 16 Jun 2026 22:30:30 +0800 Subject: [PATCH 24/29] enhance: improve PR#5 SKILL.md with clearer value props and examples - Added 'Why Small Businesses Love It' section with concrete use cases - Updated description to be more compelling and search-friendly - Added security note about env var vs --api-key - Improved code examples with multi-line formatting for readability --- skills/moark-smart-accounting/SKILL.md | 79 +++++++++++++++----------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/skills/moark-smart-accounting/SKILL.md b/skills/moark-smart-accounting/SKILL.md index e4710de..925bc72 100755 --- a/skills/moark-smart-accounting/SKILL.md +++ b/skills/moark-smart-accounting/SKILL.md @@ -1,6 +1,6 @@ --- name: moark-smart-accounting -description: Parse oral bookkeeping entries, match accounting categories, and distinguish profit/loss vs.往来 accounts for business accounting. +description: AI-powered bookkeeping for small businesses. Just say "lunch 50" — AI parses, auto-categorizes into 损益/往来 accounts, and tracks everything with monthly stats and CSV export. metadata: openclaw: emoji: "📊" @@ -9,65 +9,76 @@ metadata: primaryEnv: "GITEEAI_API_KEY" --- -# Smart Accounting -This skill allows users to manage business bookkeeping by parsing oral descriptions into structured entries, matching accounting categories, and distinguishing profit/loss vs.往来 (receivables/payables) accounts. +# Smart Accounting 📊 + +Stop drowning in spreadsheets. This skill turns spoken business expenses into **double-entry style bookkeeping records** — with AI-powered categorization, profit/loss vs. 往来 distinction, and full monthly statistics. + +## Why Small Businesses Love It + +- 🎤 **Oral-first input** — "客户 A 付款 5000" auto-detected as 应收 (receivable) +- 🧮 **Smart categorization** — auto-classifies into 损益 (P&L) vs 往来 (receivables/payables) +- 📅 **Monthly P&L stats** — see income/expense trends by month +- 📤 **CSV export** — works with Excel, 钉钉, 飞书, 金蝶, 用友 +- 🔒 **Local-first storage** — your books stay on your machine +- 🛡️ **Decimal precision** — no float rounding errors in 财务 ## Usage -Ensure you have installed the required dependencies (`pip install openai`). Use the bundled script for bookkeeping operations. +Set your API key once: `export GITEEAI_API_KEY=your_key` -**Add an entry** +**Add a business expense** ```bash -python {baseDir}/scripts/perform_smart_accounting.py --action add --text "lunch 50 yuan" --api-key YOUR_API_KEY +python {baseDir}/scripts/perform_smart_accounting.py --action add --text "client lunch 280 yuan" ``` -**Query records** +**Add a receivable (auto-detected)** ```bash -python {baseDir}/scripts/perform_smart_accounting.py --action query --date 2024-01-01 --category 餐饮 +python {baseDir}/scripts/perform_smart_accounting.py --action add --text "客户 A 付款 5000" ``` -**View statistics (with 往来 breakdown)** +**Query by date and category** ```bash -python {baseDir}/scripts/perform_smart_accounting.py --action stats --date 2024-01 +python {baseDir}/scripts/perform_smart_accounting.py --action query --date 2024-01-15 --category 餐饮 ``` -**Export as CSV** +**View monthly P&L statistics** ```bash -python {baseDir}/scripts/perform_smart_accounting.py --action query --export csv +python {baseDir}/scripts/perform_smart_accounting.py --action stats --date 2024-01 ``` -**Update a record by ID (recommended)** +**Export to CSV for your accountant** ```bash -python {baseDir}/scripts/perform_smart_accounting.py --action update --id 3 --text "lunch 60 yuan" --api-key YOUR_API_KEY +python {baseDir}/scripts/perform_smart_accounting.py --action query --export csv ``` -**Delete a record by ID (recommended)** +**Update or delete by record ID** ```bash +python {baseDir}/scripts/perform_smart_accounting.py --action update --id 3 --text "lunch 60 yuan" python {baseDir}/scripts/perform_smart_accounting.py --action delete --id 3 ``` ## Options -- `--action` / `-a` (required): Action to perform. Options: `add`, `query`, `stats`, `update`, `delete`. -- `--text` / `-t`: Bookkeeping text to parse (required for add/update). -- `--id` / `-i`: Record ID for precise update/delete (recommended over --date matching). -- `--date` / `-d`: Date filter (YYYY-MM-DD) for query/stats/update/delete. -- `--category` / `-c`: Category filter for query/stats. -- `--model` / `-m`: Model to use for parsing (default: DeepSeek-R1-0528). -- `--export`: Export format for results. Options: `json` (default), `csv`. -- `--api-key` / `-k`: Gitee AI API key (overrides GITEEAI_API_KEY env var). +- `--action` / `-a` (required): `add`, `query`, `stats`, `update`, `delete` +- `--text` / `-t`: Bookkeeping text to parse (required for add/update) +- `--id` / `-i`: Record ID for precise update/delete (recommended over --date) +- `--date` / `-d`: Date filter (YYYY-MM-DD) +- `--category` / `-c`: Category filter for query/stats +- `--model` / `-m`: Parsing model (default: DeepSeek-R1-0528) +- `--export`: Output format: `json` (default) or `csv` +- `--api-key` / `-k`: Gitee AI API key (prefer `GITEEAI_API_KEY` env var for security) ## Workflow -1. Execute the perform_smart_accounting.py script with the parameters from the user. -2. Parse the script output and find the line starting with `ACCOUNTING_RESULT:`. -3. Extract the accounting result from that line onwards. -4. Display the result to the user using markdown syntax: `📊[Accounting Result]`. +1. Run `perform_smart_accounting.py` with the user's parameters. +2. Find the line starting with `ACCOUNTING_RESULT:` in the output. +3. Extract everything from that line onwards. +4. Present to the user as: `📊 [Accounting Result]` ## Notes -- If GITEEAI_API_KEY is none, you should remind user to provide --api-key argument (not needed for query/stats/delete without LLM). -- Records are stored locally at `~/.moark/smart-accounting/expense_records.json` with schema_version "2.0". -- Use `--id` for precise update/delete. Legacy `--date` matching still supported but may affect wrong records. -- Delete action prompts for confirmation with both `--id` and `--date`. -- Stats include `by_account_type` field showing income/expense breakdown by profit_loss and 往来. -- All monetary amounts use Decimal precision (stored as strings). -- The script prints `ACCOUNTING_RESULT:` in the output - extract this result and present it to the user. +- Records stored locally at `~/.moark/smart-accounting/expense_records.json` (schema v2.0) +- Auto-distinguishes 损益 (P&L) vs 往来 (receivables/payables) accounts via AI +- All amounts use `Decimal` precision stored as strings — no float rounding errors +- Supports thousands separator in input (e.g. `5,000` → 5000) +- Delete is non-interactive safe: in CI/pipe mode, EOFError auto-cancels +- Stats include `by_account_type` for 损益/往来 breakdown +- For security, prefer `GITEEAI_API_KEY` env var over `--api-key` (not visible in `ps`/shell history) -- Gitee From f053d242d5f99997e3339103adf848fd38013041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Wed, 17 Jun 2026 00:05:47 +0800 Subject: [PATCH 25/29] fix: validate_entry uses copy() + date format validation - Use entry.copy() in validate_entry to avoid mutating caller's dict - Add date format validation (YYYY-MM-DD / YYYY-MM) in query_records - Resolves PR#5 review feedback --- .../scripts/perform_smart_accounting.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 4246c50..e7edad5 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -96,8 +96,9 @@ def save_records(records: list[dict]) -> None: def validate_entry(entry: dict) -> dict: """Validate and fix a parsed entry, falling back to defaults for illegal values. - Note: Modifies the entry dictionary in place. + Creates a copy of the entry to avoid mutating the caller's dict. """ + entry = entry.copy() # Validate type if entry.get("type") not in VALID_TYPES: entry["type"] = "expense" # default fallback @@ -175,6 +176,10 @@ def query_records(records: list[dict], category: str | None, date: str | None) - if category: filtered = [r for r in filtered if r.get("category") == category] if date: + # Validate date format before filtering + if not re.match(r"^\d{4}-\d{2}(-\d{2})?$", date): + print(f"Warning: Invalid date format '{date}'. Expected YYYY-MM or YYYY-MM-DD.", file=sys.stderr) + return [] filtered = [r for r in filtered if r.get("date", "").startswith(date)] return filtered -- Gitee From f06cef71b8c07f0ce6060cdf6b1a0e254492061d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Wed, 17 Jun 2026 02:40:16 +0800 Subject: [PATCH 26/29] fix(smart-accounting): support multiple date formats + unified error handling (PR#7 review) --- .../scripts/perform_smart_accounting.py | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index e7edad5..f15a31e 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -44,6 +44,56 @@ VALID_CATEGORIES = { VALID_ACCOUNT_TYPES = {"profit_loss", "往来"} +def normalize_date(date_str: str) -> str | None: + """Normalize various date formats to YYYY-MM-DD. + + Supported formats: + - YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD + - YYYY-MM, YYYY/MM, YYYY.MM + - YYYY年MM月DD日, YYYY年MM月 + + Returns normalized string (YYYY-MM-DD or YYYY-MM) or None if invalid. + """ + if not date_str or not isinstance(date_str, str): + return None + + date_str = date_str.strip() + + # Try YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD + patterns = [ + (r"^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$", "full"), + (r"^(\d{4})[-/.](\d{1,2})$", "month"), + (r"^(\d{4})年(\d{1,2})月(\d{1,2})日?$", "full_cn"), + (r"^(\d{4})年(\d{1,2})月$", "month_cn"), + ] + + for pattern, ptype in patterns: + match = re.match(pattern, date_str) + if match: + try: + year = int(match.group(1)) + month = int(match.group(2)) + if month < 1 or month > 12: + return None + if ptype in ("full", "full_cn"): + day = int(match.group(3)) + if day < 1 or day > 31: + return None + return f"{year:04d}-{month:02d}-{day:02d}" + else: + return f"{year:04d}-{month:02d}" + except ValueError: + return None + + return None + + +def handle_error_exit(message: str, exit_code: int = 1) -> None: + """Print error message to stderr and exit with code.""" + print(f"Error: {message}", file=sys.stderr) + sys.exit(exit_code) + + def get_api_key(provided_key: str | None) -> str | None: """Get API key from argument or environment.""" if provided_key: @@ -171,16 +221,17 @@ def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: def query_records(records: list[dict], category: str | None, date: str | None) -> list[dict]: - """Filter records by category and/or date.""" + """Filter records by category and/or date. Supports multiple date formats.""" filtered = records if category: filtered = [r for r in filtered if r.get("category") == category] if date: - # Validate date format before filtering - if not re.match(r"^\d{4}-\d{2}(-\d{2})?$", date): - print(f"Warning: Invalid date format '{date}'. Expected YYYY-MM or YYYY-MM-DD.", file=sys.stderr) + # Normalize date to support multiple formats (YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, YYYY年MM月DD日) + normalized = normalize_date(date) + if not normalized: + print(f"Warning: Invalid date format '{date}'. Expected YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, or YYYY年MM月DD日.", file=sys.stderr) return [] - filtered = [r for r in filtered if r.get("date", "").startswith(date)] + filtered = [r for r in filtered if r.get("date", "").startswith(normalized[:7] if len(normalized) == 10 else normalized)] return filtered -- Gitee From a03977991fde24302a5ed1257d5931c16dd9c683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Wed, 17 Jun 2026 03:31:08 +0800 Subject: [PATCH 27/29] =?UTF-8?q?fix(smart-accounting):=20full=20audit=20?= =?UTF-8?q?=E2=80=94=20add=20missing=20import=20argparse,=20fix=20print+in?= =?UTF-8?q?put,=20unify=20error=20handling,=20add=20response=5Fformat,=20d?= =?UTF-8?q?ate=20validation=20(PR#7=20review=20v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/perform_smart_accounting.py | 73 +++++++++++-------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index f15a31e..63b2300 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -17,6 +17,7 @@ Usage: python perform_smart_accounting.py --action stats --export csv """ +import argparse import csv import io import json @@ -120,7 +121,7 @@ def load_records() -> list[dict]: except OSError: pass print(f"Error: Data file corrupted. Backed up to {backup}", file=sys.stderr) - sys.exit(1) + handle_error_exit("Data file corrupted and backed up") def save_records(records: list[dict]) -> None: @@ -185,6 +186,7 @@ def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: """Use LLM to parse oral bookkeeping text into structured entries.""" response = client.chat.completions.create( model=model, + response_format={"type": "json_object"}, messages=[ { "role": "system", @@ -387,11 +389,13 @@ def main(): if args.id is not None: target = find_record_by_id(records, args.id) if not target: - print(f"Error: No record found with id={args.id}.", file=sys.stderr) - sys.exit(1) - print(f"Confirm delete record id={args.id}? [y/N]: ", end="", file=sys.stderr) - confirm = input().strip().lower() - if confirm != "y": + handle_error_exit(f"No record found with id={args.id}") + try: + confirm = input(f"Confirm delete record id={args.id}? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nDelete cancelled.", file=sys.stderr) + sys.exit(0) + if confirm not in ("y", "yes"): print("Delete cancelled.", file=sys.stderr) sys.exit(0) remaining = [r for r in records if r.get("id") != args.id] @@ -402,9 +406,12 @@ def main(): if not to_delete: print(f"No records found for date={args.date}, category={args.category}.", file=sys.stderr) sys.exit(0) - print(f"Will delete {len(to_delete)} record(s). Confirm? [y/N]: ", end="", file=sys.stderr) - confirm = input().strip().lower() - if confirm != "y": + try: + confirm = input(f"Will delete {len(to_delete)} record(s). Confirm? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nDelete cancelled.", file=sys.stderr) + sys.exit(0) + if confirm not in ("y", "yes"): print("Delete cancelled.", file=sys.stderr) sys.exit(0) to_delete_ids = {r["id"] for r in to_delete if "id" in r} @@ -416,18 +423,18 @@ def main(): save_records(remaining) print(f"\nACCOUNTING_RESULT: Deleted {actual_count} record(s). {len(remaining)} remaining.") else: - print("Error: --id or --date is required for delete action.", file=sys.stderr) - sys.exit(1) + handle_error_exit("--id or --date is required for delete action") return # Actions that need API key api_key = get_api_key(args.api_key) if not api_key: - print("Error: No API key provided.", file=sys.stderr) - print("Please either:", file=sys.stderr) - print(" 1. Provide --api-key argument", file=sys.stderr) - print(" 2. Set GITEEAI_API_KEY environment variable", file=sys.stderr) - sys.exit(1) + handle_error_exit( + "No API key provided.\n" + "Please either:\n" + " 1. Provide --api-key argument\n" + " 2. Set GITEEAI_API_KEY environment variable" + ) client = OpenAI( base_url=API_BASE_URL, @@ -437,14 +444,16 @@ def main(): try: if args.action == "add": if not args.text: - print("Error: --text is required for add action.", file=sys.stderr) - sys.exit(1) + handle_error_exit("--text is required for add action") + + # Validate date format if provided + if args.date and not normalize_date(args.date): + handle_error_exit(f"Invalid date format: '{args.date}'. Use YYYY-MM-DD, YYYY/MM/DD, or YYYY.MM.DD") print(f"Parsing bookkeeping entry: {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print("Error: Could not parse the entry. LLM returned invalid or empty response.", file=sys.stderr) - sys.exit(1) + handle_error_exit("Could not parse the entry. LLM returned invalid or empty response.") today = args.date or datetime.now().strftime("%Y-%m-%d") for entry in entries: @@ -458,20 +467,21 @@ def main(): elif args.action == "update": if not args.text: - print("Error: --text is required for update action.", file=sys.stderr) - sys.exit(1) + handle_error_exit("--text is required for update action") + + # Validate date format if provided + if args.date and not normalize_date(args.date): + handle_error_exit(f"Invalid date format: '{args.date}'. Use YYYY-MM-DD, YYYY/MM/DD, or YYYY.MM.DD") if args.id is not None: # Precise update by ID target = find_record_by_id(records, args.id) if not target: - print(f"Error: No record found with id={args.id}.", file=sys.stderr) - sys.exit(1) + handle_error_exit(f"No record found with id={args.id}") print(f"Re-parsing entry for update (id={args.id}): {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print("Error: Could not parse the entry. LLM returned invalid or empty response.", file=sys.stderr) - sys.exit(1) + handle_error_exit("Could not parse the entry. LLM returned invalid or empty response.") new_entry = entries[0] new_entry["id"] = args.id new_entry["date"] = args.date or target.get("date", datetime.now().strftime("%Y-%m-%d")) @@ -488,8 +498,7 @@ def main(): print(f"Re-parsing entry for update (date={args.date}): {args.text}") entries = parse_entry(client, args.text, args.model) if not entries: - print("Error: Could not parse the entry. LLM returned invalid or empty response.", file=sys.stderr) - sys.exit(1) + handle_error_exit("Could not parse the entry. LLM returned invalid or empty response.") updated = False for i, r in enumerate(records): if r.get("date", "").startswith(args.date): @@ -507,12 +516,14 @@ def main(): else: print("No matching record found to update.", file=sys.stderr) else: - print("Error: --id or --date is required for update action.", file=sys.stderr) - sys.exit(1) + handle_error_exit("--id or --date is required for update action") + except KeyboardInterrupt: + print("\nOperation cancelled by user.", file=sys.stderr) + sys.exit(130) except Exception as e: print(f"\nError: {e}", file=sys.stderr) - sys.exit(1) + handle_error_exit(str(e)) if __name__ == "__main__": -- Gitee From 31e6cccb02cb6f110708423a83294419fe49520d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Wed, 17 Jun 2026 04:27:36 +0800 Subject: [PATCH 28/29] fix(v2): resolve all review issues for smart-accounting Blocker fix: - Date filter now distinguishes exact day match (YYYY-MM-DD) from month prefix match (YYYY-MM). Previously [:7] hardcode caused full dates to match entire month's data Improvement: - normalize_date() now validates real calendar dates via datetime.date(), rejecting impossible dates like Feb 30 - add/update actions: normalize date before storing to prevent write/query format mismatch (same fix as expense-tracker) - legacy update-by-date: uses normalized date for matching --- .../scripts/perform_smart_accounting.py | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index 63b2300..baecafa 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -80,6 +80,11 @@ def normalize_date(date_str: str) -> str | None: day = int(match.group(3)) if day < 1 or day > 31: return None + # Validate with actual calendar (rejects Feb 30, etc.) + try: + datetime.date(year, month, day) + except ValueError: + return None return f"{year:04d}-{month:02d}-{day:02d}" else: return f"{year:04d}-{month:02d}" @@ -223,17 +228,28 @@ def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: def query_records(records: list[dict], category: str | None, date: str | None) -> list[dict]: - """Filter records by category and/or date. Supports multiple date formats.""" + """Filter records by category and/or date. + + Date matching logic: + - Full date (YYYY-MM-DD, 10 chars): exact match against stored dates + - Month only (YYYY-MM, 7 chars): prefix match (entire month) + """ filtered = records if category: filtered = [r for r in filtered if r.get("category") == category] if date: - # Normalize date to support multiple formats (YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, YYYY年MM月DD日) + # Normalize date to support multiple formats normalized = normalize_date(date) if not normalized: print(f"Warning: Invalid date format '{date}'. Expected YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, or YYYY年MM月DD日.", file=sys.stderr) return [] - filtered = [r for r in filtered if r.get("date", "").startswith(normalized[:7] if len(normalized) == 10 else normalized)] + # Full date (10 chars): exact match; month (7 chars): prefix match + if len(normalized) == 10: + # Exact day match + filtered = [r for r in filtered if r.get("date", "") == normalized] + else: + # Month prefix match + filtered = [r for r in filtered if r.get("date", "").startswith(normalized)] return filtered @@ -455,7 +471,9 @@ def main(): if not entries: handle_error_exit("Could not parse the entry. LLM returned invalid or empty response.") - today = args.date or datetime.now().strftime("%Y-%m-%d") + # Normalize date before storing (ensures query always matches) + raw_date = args.date or datetime.now().strftime("%Y-%m-%d") + today = normalize_date(raw_date) or raw_date for entry in entries: entry["date"] = today entry["id"] = next_id(records) @@ -484,7 +502,8 @@ def main(): handle_error_exit("Could not parse the entry. LLM returned invalid or empty response.") new_entry = entries[0] new_entry["id"] = args.id - new_entry["date"] = args.date or target.get("date", datetime.now().strftime("%Y-%m-%d")) + raw_update_date = args.date or target.get("date", datetime.now().strftime("%Y-%m-%d")) + new_entry["date"] = normalize_date(raw_update_date) or raw_update_date # Replace in records for i, r in enumerate(records): if r.get("id") == args.id: @@ -499,12 +518,20 @@ def main(): entries = parse_entry(client, args.text, args.model) if not entries: handle_error_exit("Could not parse the entry. LLM returned invalid or empty response.") + # Normalize date for consistent matching + normalized_update = normalize_date(args.date) or args.date updated = False for i, r in enumerate(records): - if r.get("date", "").startswith(args.date): - if args.category and r.get("category") != args.category: - continue - entries[0]["date"] = args.date + # Use exact match for full date, prefix match for month + if len(normalized_update) == 10: + date_match = r.get("date", "") == normalized_update + else: + date_match = r.get("date", "").startswith(normalized_update) + if not date_match: + continue + if args.category and r.get("category") != args.category: + continue + entries[0]["date"] = normalized_update entries[0]["id"] = r.get("id", next_id(records)) records[i] = entries[0] updated = True -- Gitee From b64ca87bd5076ee1d72034630f6a5fc7b0ad6c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E6=9D=8E=E8=AF=B4?= Date: Wed, 17 Jun 2026 04:50:44 +0800 Subject: [PATCH 29/29] fix(v3): resolve all 3 review issues for smart-accounting Blocker fix: - Fixed IndentationError in update-by-date legacy branch: entries[0][id] and subsequent assignments were at wrong indent level (24 vs 20 spaces), causing Python syntax parse failure Improvements: - Month-level delete now warns user before confirmation when date normalizes to 7-char prefix (prevents accidental bulk deletion) - JSON parsing: try json.loads() first before falling back to regex markdown-code-block extraction (more robust) --- .../scripts/perform_smart_accounting.py | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py index baecafa..0adfa1f 100755 --- a/skills/moark-smart-accounting/scripts/perform_smart_accounting.py +++ b/skills/moark-smart-accounting/scripts/perform_smart_accounting.py @@ -213,18 +213,21 @@ def parse_entry(client: OpenAI, text: str, model: str) -> list[dict]: ) raw = response.choices[0].message.content if response.choices and response.choices[0].message else None content = raw.strip() if raw else "[]" - match = re.search(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL) - if match: - content = match.group(1) + # Try direct JSON parse first; fall back to markdown-code-block extraction try: entries = json.loads(content) - if not isinstance(entries, list): - entries = [entries] - # Validate each entry - return [validate_entry(e) for e in entries] except json.JSONDecodeError: - print(f"Warning: Could not parse LLM response as JSON: {content}", file=sys.stderr) - return [] + match = re.search(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL) + if match: + content = match.group(1) + try: + entries = json.loads(content) + except json.JSONDecodeError: + print(f"Warning: Could not parse LLM response as JSON: {content}", file=sys.stderr) + return [] + if not isinstance(entries, list): + entries = [entries] + return [validate_entry(e) for e in entries] def query_records(records: list[dict], category: str | None, date: str | None) -> list[dict]: @@ -418,10 +421,20 @@ def main(): save_records(remaining) print(f"\nACCOUNTING_RESULT: Deleted record id={args.id}. {len(remaining)} remaining.") elif args.date: + normalized = normalize_date(args.date) + if not normalized: + handle_error_exit(f"Invalid date format: '{args.date}'") to_delete = query_records(records, args.category, args.date) if not to_delete: print(f"No records found for date={args.date}, category={args.category}.", file=sys.stderr) sys.exit(0) + # Warn user when deleting by month prefix + if len(normalized) == 7: + print( + f"Warning: '{args.date}' was interpreted as month-level filter. " + f"Will delete ALL {len(to_delete)} record(s) for {normalized}.", + file=sys.stderr, + ) try: confirm = input(f"Will delete {len(to_delete)} record(s). Confirm? [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): @@ -532,10 +545,10 @@ def main(): if args.category and r.get("category") != args.category: continue entries[0]["date"] = normalized_update - entries[0]["id"] = r.get("id", next_id(records)) - records[i] = entries[0] - updated = True - break + entries[0]["id"] = r.get("id", next_id(records)) + records[i] = entries[0] + updated = True + break if updated: save_records(records) print("\nACCOUNTING_RESULT: Updated record.") -- Gitee