From 20e45d5c8bdcf332bbb81a234fea9a607f1d7da5 Mon Sep 17 00:00:00 2001 From: Cxx0822 <1556464090@qq.com> Date: Thu, 2 Jul 2026 22:17:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=9B=B4=E6=96=B0=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 6 +--- .gitignore | 5 +-- .idea/sweet-hut-agent.iml | 3 +- .idea/workspace.xml | 64 ++++++++++++++++++------------------- Dockerfile | 11 +++++++ agent.py | 11 ------- agent/bill.py | 11 +++++++ agent/model.py | 22 +++++++++++++ config/db.py | 31 ------------------ main.py | 25 ++++++++------- model.py | 5 --- modes/agent.py | 4 +++ prompt/bill.py | 49 ++++++++++++++++++++++++++++ prompt/sql.py | 18 ----------- prompt/system.py | 23 -------------- prompt/table.py | 9 ------ requirements.txt | 23 +++++++------- router.py | 11 ------- services.py | 46 +++++++++++++++++++++++++++ services/agent_service.py | 44 ------------------------- tools/bill.py | 67 +++++++++++++++++++++++++++++++++++++++ 21 files changed, 270 insertions(+), 218 deletions(-) create mode 100644 Dockerfile delete mode 100644 agent.py create mode 100644 agent/bill.py create mode 100644 agent/model.py delete mode 100644 config/db.py delete mode 100644 model.py create mode 100644 modes/agent.py create mode 100644 prompt/bill.py delete mode 100644 prompt/sql.py delete mode 100644 prompt/system.py delete mode 100644 prompt/table.py delete mode 100644 router.py create mode 100644 services.py delete mode 100644 services/agent_service.py create mode 100644 tools/bill.py diff --git a/.env b/.env index 2511459..0979efa 100644 --- a/.env +++ b/.env @@ -1,5 +1 @@ -SQLITE_DB_PATH="sweet-hut.db" -DB_URL="mysql+pymysql://root:estun%40medical@localhost/sweet_hut?charset=utf8mb4" -MODEL_NAME="qwen3.6-plus" -MODEL_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" -MODEL_API_KEY="sk-52bcd98e9c1d45908437c4e8706eefff" \ No newline at end of file +TAVILY_API_KEY=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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index eba1255..7ddac70 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,4 @@ media/ .DS_Store logs/ -packages/ -*.db -*.db-shm -*.db-wal \ No newline at end of file +packages/ \ No newline at end of file diff --git a/.idea/sweet-hut-agent.iml b/.idea/sweet-hut-agent.iml index 3d8e3d3..e408242 100644 --- a/.idea/sweet-hut-agent.iml +++ b/.idea/sweet-hut-agent.iml @@ -3,8 +3,9 @@ + - + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index b3b372e..06d7c2f 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,23 +5,18 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + - - { + "keyToString": { + "FastAPI.sweet-hut-agent.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "git-widget-placeholder": "master", + "last_opened_file_path": "D:/Cxx/PythonProjects/sweet-hut-agent/prompt", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "vue.rearranger.settings.migration": "true" } -}]]> +} @@ -83,6 +79,7 @@ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0295eb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim +WORKDIR /app +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime +RUN echo 'Asia/Shanghai' > /etc/timezone +COPY ./packages /app/packages +COPY requirements.txt /app/ +RUN pip install --no-cache-dir --no-index --find-links=/app/packages -r requirements.txt +COPY . /app/ +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +# pip download -r requirements.txt -d ./packages --only-binary=:all: --platform manylinux2014_x86_64 -i https://pypi.tuna.tsinghua.edu.cn/simple diff --git a/agent.py b/agent.py deleted file mode 100644 index 229299d..0000000 --- a/agent.py +++ /dev/null @@ -1,11 +0,0 @@ -from langchain.chat_models import init_chat_model - -import os - -chat_model = init_chat_model( - model=os.getenv("MODEL_NAME"), - model_provider="openai", - base_url=os.getenv("MODEL_BASE_URL"), - api_key=os.getenv("MODEL_API_KEY"), - temperature=1.5, -) diff --git a/agent/bill.py b/agent/bill.py new file mode 100644 index 0000000..ce3c548 --- /dev/null +++ b/agent/bill.py @@ -0,0 +1,11 @@ +from langchain.agents import create_agent + +from agent.model import model +from prompt.bill import bill_prompt +from tools.bill import bill_search + +bill_agent = create_agent( + model=model, + tools=[bill_search], + system_prompt=bill_prompt +) diff --git a/agent/model.py b/agent/model.py new file mode 100644 index 0000000..7cdd87d --- /dev/null +++ b/agent/model.py @@ -0,0 +1,22 @@ +from langchain.chat_models import init_chat_model + +# model = init_chat_model( +# model="glm-5.1", +# model_provider="openai", +# base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", +# api_key="sk-52bcd98e9c1d45908437c4e8706eefff" +# ) + +# model = init_chat_model( +# model="GLM-5-Turbo", +# model_provider="openai", +# base_url="https://open.bigmodel.cn/api/coding/paas/v4", +# api_key="ea3f96fd542f4bab805a719cf3063cd6.HCRhCV7nqwkDsVKm", +# ) + +model = init_chat_model( + model="deepseek-v4-flash", + model_provider="openai", + base_url="https://api.deepseek.com", + api_key="sk-0b237d41f6bc44fc9732ea66bd7eade0" +) \ No newline at end of file diff --git a/config/db.py b/config/db.py deleted file mode 100644 index f80b8b9..0000000 --- a/config/db.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -from dotenv import load_dotenv -from sqlalchemy import create_engine -from sqlalchemy import text - -load_dotenv() - -engine = create_engine( - os.getenv("DB_URL"), - pool_pre_ping=True, -) - -conn = engine.connect() - - -def is_select(sql: str) -> bool: - return sql.lower().lstrip().startswith("select") - - -def execute_sql(sql: str): - if not is_select(sql): - raise ValueError("只允许 SELECT 查询") - - result = conn.execute(text(sql)) - rows = result.fetchall() - columns = result.keys() - return { - "columns": list(columns), - "rows": rows - } diff --git a/main.py b/main.py index d9e1fbc..28e5f16 100644 --- a/main.py +++ b/main.py @@ -1,22 +1,25 @@ -from dotenv import load_dotenv from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from router import router +from starlette.middleware.cors import CORSMiddleware +from starlette.responses import StreamingResponse -# 初始化FastAPI -app = FastAPI( - title="Sweet Hut Agent", - description="智能体", - version="0.1.0" -) +from modes.agent import QueryRequest +from services import query_bill_agent + +app = FastAPI(title="Integrate Agent API") app.add_middleware( CORSMiddleware, allow_origins=["*"], + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -load_dotenv() -app.include_router(router) +@app.post("/query/bill") +async def query_agent(query: QueryRequest): + """流式对话""" + return StreamingResponse( + query_bill_agent(query), + media_type="text/event-stream" + ) diff --git a/model.py b/model.py deleted file mode 100644 index 1ee745d..0000000 --- a/model.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel - -class ChatRequest(BaseModel): - type: str - message: str diff --git a/modes/agent.py b/modes/agent.py new file mode 100644 index 0000000..882e9c8 --- /dev/null +++ b/modes/agent.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel + +class QueryRequest(BaseModel): + message: str \ No newline at end of file diff --git a/prompt/bill.py b/prompt/bill.py new file mode 100644 index 0000000..150d81b --- /dev/null +++ b/prompt/bill.py @@ -0,0 +1,49 @@ +bill_prompt = """ +你是一个个人账单查询助手,负责帮助用户查询账单记录。 + +# 可调用的工具 +- bill_search(start_date, end_date) + - 用于查询指定日期范围内的所有账单 + - 日期格式:YYYY-MM-DD + - 该接口不区分收入和支出,会一并返回 + +# 日期推断规则(非常重要) +当用户未明确给出日期时,你必须先按以下规则推断 start_date 和 end_date,再调用工具: + +- 近期 / 最近:今天之前的 30 天 +- 本月:当前自然月(如 2026-08-01 ~ 2026-08-31) +- 上个月:上一个自然月 +- 本季度:当前季度(Q1: 01-01~03-31,依此类推) +- 上半年:01-01 ~ 06-30 +- 下半年:07-01 ~ 12-31 +- 今年 / 本年:当前自然年 +- 去年:上一年自然年 +- 无时间描述:默认最近 90 天 + +# 示例 +- 用户:帮我看看最近的账单 + → 推断为最近 30 天 → 调用 bill_search + +- 用户:查一下这个月的支出 + → 推断为本月日期范围 → 调用 bill_search,后续再用文本过滤支出 + +- 用户:上半年收入怎么样 + → 推断为 01-01 ~ 06-30 → 调用 bill_search,再自行判断 type = income + +# 返回字段说明(JSON) +- id: 账单ID +- bookName: 账本名称 +- type: 账单类型(income / expense) +- category: 账单类别 +- location: 账单地点 +- payAccount: 支付账户 +- amount: 金额 +- content: 账单内容 +- remark: 备注 +- date: 账单日期(yyyy-MM-dd) + +# 行为准则 +- 用户未指定日期时,必须先按上述规则推断日期范围 +- 查询前应在回复中说明使用的查询条件,例如:“为您查询本月(2026-08-01 ~ 2026-08-31)的支出账单:” +- 查询完成后,对账单进行解读(收入 / 支出 / 分类 / 汇总) +""" \ No newline at end of file diff --git a/prompt/sql.py b/prompt/sql.py deleted file mode 100644 index 56eb12f..0000000 --- a/prompt/sql.py +++ /dev/null @@ -1,18 +0,0 @@ -from langchain_core.prompts import PromptTemplate - -SQL_PROMPT = PromptTemplate.from_template( - """ -你是一个 MySQL 专家。 -请根据用户问题生成一条可执行的 MySQL 查询语句。 -只返回 SQL,不要解释,不要加 ```。 - -数据库结构: -{schema} - -数据库补充: -{option} - -用户问题: -{question} -""" -) \ No newline at end of file diff --git a/prompt/system.py b/prompt/system.py deleted file mode 100644 index 4a12529..0000000 --- a/prompt/system.py +++ /dev/null @@ -1,23 +0,0 @@ -from langchain_core.prompts import PromptTemplate - -BILL_PROMPT = PromptTemplate.from_template( - """ -你是一位家庭账单助手,负责用清晰、专业、易懂的中文回答用户的问题。 - -用户问题: -{question} - -查询到的数据: -{result} - -回答要求: -1. 仅基于上方数据进行回答,不要编造内容。 -2. 如果数据为空,直接回答:「没有找到相关数据」。 -3. 金额统一保留两位小数,并带上“元”。 -4. 优先给出结论和总结,再选择性补充关键明细。 -5. 不要输出 SQL、字段名、JSON 或技术细节。 -6. 语气自然,像在和家人沟通。 - -现在请根据上述内容回答问题。 -""" -) diff --git a/prompt/table.py b/prompt/table.py deleted file mode 100644 index b257c3a..0000000 --- a/prompt/table.py +++ /dev/null @@ -1,9 +0,0 @@ -BILL_TABLE_SCHEMA = """ -【表关系】 -bill_record.book_id → bill_book.id -bill_record.category_id → bill_category.id -bill_record.pay_id → bill_pay.id - -【字段说明】 -bill_record.type / bill_category.type 取值:'expensive'=支出,'income'=收入 -""" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5b70968..664cf23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,11 @@ -fastapi~=0.136.3 -uvicorn~=0.48.0 -langchain~=1.3.2 -langchain-openai~=1.2.2 -langchain-tavily~=0.2.18 -langgraph~=1.2.2 -langgraph-checkpoint-sqlite~=3.1.0 -pydantic~=2.13.4 -langchain-core~=1.4.0 -python-dotenv~=1.2.2 -langchain-community~=0.4.2 -pymysql~=1.2.0 +pymysql~=1.1.2 +requests~=2.32.5 +fastapi~=0.135.1 +langchain-community~=0.4.1 +pydantic~=2.12.5 +langchain-openai~=1.1.10 +uvicorn~=0.23.0 +SQLAlchemy~=2.0.48 +langchain-core~=1.2.17 +langchain~=1.2.10 +starlette~=0.52.1 \ No newline at end of file diff --git a/router.py b/router.py deleted file mode 100644 index 1df5fcb..0000000 --- a/router.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi import APIRouter - -from model import ChatRequest -from services.agent_service import query - -router = APIRouter() - - -@router.post("/chat") -async def chat_endpoint(request: ChatRequest): - return query(request) diff --git a/services.py b/services.py new file mode 100644 index 0000000..8fc6ba3 --- /dev/null +++ b/services.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from langchain_core.messages import HumanMessage, AIMessageChunk + +from agent.bill import bill_agent +from modes.agent import QueryRequest + + +async def query_bill_agent(query: QueryRequest): + try: + print(query) + today = datetime.today().strftime("%Y-%m-%d") + + message_content = ( + f"[当前系统日期: {today}]\n\n" + f"{query.message}" + ) + + message = HumanMessage(content=message_content) + + tool_called = False + content_generate = False + + # 流式调用Agent + for chunk, metadata in bill_agent.stream( + {"messages": [message]}, + stream_mode="messages" + ): + # print(chunk) + if isinstance(chunk, AIMessageChunk): + if chunk.content: + if not content_generate: + content_generate = True + yield "\n 📋 **分析结果**:\n" + yield chunk.content + + for tc in chunk.tool_call_chunks: + if tc.get("args"): + if not tool_called: + tool_called = True + yield "🛠️ **开始查询数据**:查询日期:" + yield tc["args"] + + except Exception as e: + print(f"\n[错误]: {str(e)}") + yield "信息检索失败,请重新输入问题提问" diff --git a/services/agent_service.py b/services/agent_service.py deleted file mode 100644 index 3a178de..0000000 --- a/services/agent_service.py +++ /dev/null @@ -1,44 +0,0 @@ -import os - -from langchain.agents import create_agent -from langchain_community.utilities import SQLDatabase -from langchain_core.messages import HumanMessage - -from agent import chat_model -from config.db import execute_sql -from model import ChatRequest -from prompt.sql import SQL_PROMPT -from prompt.system import BILL_PROMPT -from prompt.table import BILL_TABLE_SCHEMA - -tables = { - "bill": ["bill_book", "bill_category", "bill_pay", "bill_record"] -} - - -def query(request: ChatRequest): - try: - db = SQLDatabase.from_uri(os.getenv("DB_URL"), include_tables=tables[request.type]) - table_info = db.get_table_info() - - sql = chat_model.invoke( - SQL_PROMPT.format(schema=table_info, option=BILL_TABLE_SCHEMA, question=request.message) - ).content.strip() - - print("sql: ", sql) - - result = execute_sql(sql) - - print("result: ", result) - - agent = create_agent( - model=chat_model, - system_prompt=BILL_PROMPT.format(question=request.message, result=result), - ) - - response = agent.invoke({"message": HumanMessage(content=request.message)}) - print("response: ", response) - - return response["messages"][-1].content - except Exception as e: - print(f"\n[错误]: {str(e)}") diff --git a/tools/bill.py b/tools/bill.py new file mode 100644 index 0000000..dfa5c07 --- /dev/null +++ b/tools/bill.py @@ -0,0 +1,67 @@ +from langchain_core.tools import tool +import requests + +BASE_URL = "https://cxx0822.s.3q.hair/home-api/" +USERNAME = "Cxx0822" +PASSWORD = "19940822Cxx" + + +def get_token() -> str: + """ + 调用登录接口,返回 saToken.tokenValue + """ + params = { + "name": USERNAME, + "password": PASSWORD + } + + try: + resp = requests.post(f"{BASE_URL}session", params=params, timeout=10) + resp.raise_for_status() + data = resp.json() + + # 按你给的返回结构取值 + token_value = data["saToken"]["tokenValue"] + return token_value + + except requests.exceptions.RequestException as e: + raise RuntimeError(f"登录失败,无法获取 token: {e}") + except KeyError as e: + raise RuntimeError(f"登录响应结构异常,未找到 tokenValue: {e}") + + +@tool +def bill_search(start_date: str, end_date: str) -> str: + """ + 查询账单列表。 + + 参数说明: + - start_date (必填): + - 开始日期,格式:YYYY-MM-DD + - 示例:2026-06-01 + - end_date (必填): + - 结束日期,格式:YYYY-MM-DD + - 示例:2026-07-01 + """ + + print(f"start_date: {start_date}, end_date: {end_date}") + + params = { + "bookName": "", + "type": "", + "startDate": start_date, + "endDate": end_date + } + + token = get_token() + + headers = { + "satoken": token + } + + try: + resp = requests.get(f"{BASE_URL}bill/summary", params=params, headers=headers, timeout=10) + resp.raise_for_status() + return resp.text + except Exception as e: + return f"查询账单失败: {e}"