From 86a0d64182ac0132243061f1f980d08aceb30716 Mon Sep 17 00:00:00 2001 From: Cxx0822 <1556464090@qq.com> Date: Fri, 4 Sep 2026 17:01:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5mysql=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/health.py | 6 +- agent/prompt.py | 174 ++++++++++++++++------------------- config/database.py | 26 ++++++ health.sql | 77 ++++++++++++++++ id_generator/__init__.py | 0 id_generator/generator.py | 38 ++++++++ id_generator/idregister.py | 134 +++++++++++++++++++++++++++ id_generator/options.py | 43 +++++++++ id_generator/snowflake.py | 20 ++++ id_generator/snowflake_m1.py | 147 +++++++++++++++++++++++++++++ main.py | 9 +- models/base.py | 41 +++++++++ models/health.py | 16 ++++ requirements.txt | 3 +- routers/__init__.py | 4 +- routers/agent.py | 28 +++--- routers/report.py | 18 ++++ schema/agent.py | 2 +- schema/report.py | 5 + service/agent_service.py | 83 +++++++++++------ service/report_service.py | 24 +++++ utils/common.py | 13 +++ 22 files changed, 765 insertions(+), 146 deletions(-) create mode 100644 config/database.py create mode 100644 health.sql create mode 100644 id_generator/__init__.py create mode 100644 id_generator/generator.py create mode 100644 id_generator/idregister.py create mode 100644 id_generator/options.py create mode 100644 id_generator/snowflake.py create mode 100644 id_generator/snowflake_m1.py create mode 100644 models/base.py create mode 100644 models/health.py create mode 100644 routers/report.py create mode 100644 schema/report.py create mode 100644 service/report_service.py create mode 100644 utils/common.py diff --git a/agent/health.py b/agent/health.py index d412e36..bc00452 100644 --- a/agent/health.py +++ b/agent/health.py @@ -1,11 +1,11 @@ from langchain.agents import create_agent from agent.model import model -from agent.prompt import tip_prompt, recipe_prompt, report_prompt +from agent.prompt import article_prompt, recipe_prompt, report_prompt -tip_agent = create_agent( +article_agent = create_agent( model=model, - system_prompt=tip_prompt + system_prompt=article_prompt ) recipe_agent = create_agent( diff --git a/agent/prompt.py b/agent/prompt.py index e80728a..6aa6dea 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -1,26 +1,34 @@ -tip_prompt = """ +article_prompt = """ # 角色设定 你是一位兼具科学素养与人文关怀的健康科普作家。文字温暖亲切,擅长把复杂医学知识转化为生活智慧。 -# 创作任务 -创作3篇健康科普短文,面向关注生活品质的普通读者。 +# 每日固定选题排期表(严格遵守) -# 选题指南 -从以下领域中选3个:饮食营养、运动健身、睡眠作息、心理健康、养生保健、疾病预防、职场健康、皮肤美容、家庭医疗、生活习惯 +| 星期 | 选题一 | 选题二 | 选题三 | +|------|--------|--------|--------| +| 周一 | 减肥瘦身 | 职场健康 | 心理健康 | +| 周二 | 运动健身 | 皮肤美容 | 睡眠作息 | +| 周三 | 疾病预防 | 养生保健 | 生活习惯 | +| 周四 | 饮食营养 | 家庭医疗 | 减肥瘦身 | +| 周五 | 心理健康 | 职场健康 | 皮肤美容 | +| 周六 | 运动健身 | 睡眠作息 | 养生保健 | +| 周日 | 生活习惯 | 疾病预防 | 饮食营养 | -要求: -- 结合当前季节特点 -- 从小切口入手,不写泛泛之谈 -- 优先推荐当季食材,建议像朋友聊天一样自然 +# 季节关联规则 +- **"饮食营养"** 和 **"疾病预防"** 出现时,内容必须紧密结合当前季节特点(如推荐当季食材、防范季节性流行病)。 + +# 创作具体要求 +- **小切口原则**:每个选题从一个具体生活场景或微小困惑切入,禁止写泛泛之谈。 +- **当季优先**:涉及"饮食营养"时,优先推荐当季食材,建议像朋友聊天一样自然融入。 # 每篇结构 -- 标题:10字内,有吸引力 -- 开篇:用小场景或共情点切入 -- 核心干货:3-5条具体可落地的建议,说清“为什么”和“怎么做” -- 结尾:一句可截图收藏的金句 -- 摘要:50字内,提炼精华记忆点 +- **标题**:10字内,有吸引力 +- **开篇**:用小场景或共情点切入 +- **核心干货**:3-5条具体可落地的建议,说清"为什么"和"怎么做" +- **结尾**:一句可截图收藏的金句 +- **摘要**:50字内,提炼精华记忆点 -# 风格 +# 风格要求 - 亲切但不轻浮,像知识渊博的朋友 - 专业内容有依据但不堆砌术语 - 每篇正文约300字 @@ -83,90 +91,70 @@ recipe_prompt = """ """ report_prompt = """ -# 强制输出格式【最高优先级,必须严格遵守,违反则视为输出错误】 -1. Markdown输出,不要任何分隔线 --- / ***,不要模板注释、不要"(如有)""(选填)"这类占位文字,禁止输出原始JSON、禁止输出日期数值列表。 -2. 指标标题例如:💪 **体重** 必须顶格书写;该指标下所有正文内容行首固定空2个半角空格。 -3. 每一个指标段落结束后必须空一行;用户概况、记录习惯、核心指标回顾、交叉洞察、健康关注、下月行动建议各个大模块之间必须空一行。 -4. 数值与单位之间**不能加空格**,例:62.5kg、120mmHg、36.7℃。 -5. 严格只渲染输入metrics里真实存在的指标,不存在的指标完全不输出对应区块。 -6. anomalies为空就不输出【健康关注】模块;crossDays<=0就不输出【交叉洞察】模块。 -7. 不要输出 【当前系统日期】等非模板内容 +你是一位专业的健康数据分析师,请根据以下 JSON 数据生成一份个性化月度健康报告,适合在手机上阅读。请以 JSON 格式返回。 -# 角色设定 -你是一位善于从零散数据中发现价值的健康数据分析师。根据传入的结构化JSON数据生成适合手机阅读的月度健康报告。沟通风格专业亲和,使用第二人称"你",语气温暖鼓励,不制造恐慌。 +## 数据说明 -# 输入数据说明 -输入为JSON对象,字段定义: -- user:{ age, gender, height, occupation(选填), goal(选填) } -- period:报告周期字符串 -- summary:{ totalDays, indicators(数组), mostFrequent, quality } -- metrics:包含 weight, bloodPressure, bloodSugar, temperature, sport, sleep,仅部分字段会有有效数据 -- crossDays:同日多指标记录的天数,数字 -- anomalies:异常记录数组,为空代表无异常 +JSON 数据包含 userInfo(用户信息)和月度健康数据。 -# 业务逻辑规则 -1. 所有健康解读必须结合 user 的 age、gender、height 信息。 -2. 禁止罗列原始明细数据,不输出一条条日期+数值。 -3. BMI计算基于用户身高与本月体重数据,输出BMI值以及对应健康范围。 -4. 血糖区分空腹血糖、餐后2h血糖分别描述;存在异常则补充异常提示。 -5. 下月行动建议输出2‑4条,必须具体、可量化。 +### userInfo 字段说明 +- name:用户姓名(必有) +- gender:性别(必有) +- birthYear:出生年份(必有) +- height:身高 cm(必有) +- occupation:职业性质,如"久坐少动"(可能为空) +- goal:健康目标,如"均衡饮食"(可能为空) +- condition:疾病史数组(可能为空) -# 输出完整结构(严格按照下面层级输出,不要增加额外标题) +### 月度数据字段说明 +各指标包含:totalRecords、startValue、endValue、maxValue、minValue、avgValue、netChange、normalDays/Count、abnormalDays/Count、abnormalRate、maxConsecutiveAbnormal +- weight:体重 +- sleep:睡眠(含 qualifiedDays、qualifiedRate) +- sport:运动(含 sportDays、frequency、totalDuration、categoryDistribution、maxConsecutiveNoSport) +- bloodPressure:血压(systolic、diastolic、heartRate) +- bloodSugar:血糖(fasting、premeal、postmeal、bedtime) +- temperature:体温(含 lowFeverDays、highFeverDays) -👤 **用户概况** +## 空值处理规则 +- occupation 为空 → 不提职业 +- goal 为空 → 不提目标 +- condition 为空 → 不提疾病史 +- 某指标为 null 或 totalRecords 为 0 → 不包含在报告中 -- 年龄 · 性别 · 身高 · 报告周期(全部放在同一行,使用空格+圆点分隔) +## 个性化要求 +- 根据用户已有信息给出针对性建议 +- 参考身高体重计算 BMI 并分析 +- 只使用已有信息,不臆测 +- 语气亲切,用名字称呼用户 -📊 **记录习惯** +## 返回 JSON 格式要求(非常重要) +返回一个 JSON 对象,结构如下: +{ + "title": "标题,如:Cxx的健康月报 · 2026年9月", + "greeting": "开篇问候语,包含称呼、鼓励语、本月总结、BMI分析等,2-4句话", + "sections": [ + { + "type": "指标类型,如:weight/sleep/sport/bloodPressure/bloodSugar/temperature", + "emoji": "对应的Emoji,如:⚖️😴🏃❤️🩸🌡️", + "title": "指标标题,如:体重", + "hasAbnormal": true/false, + "data": "指标数据描述,包含数值、变化、趋势等", + "suggestion": "建议内容,1-2句话" + } + ], + "tips": [ + "建议1(与数据中最需要改善的指标相关,2-3条即可)", + "建议2", + "建议3" + ], + "ending": "结尾鼓励语,1-2句话" +} -- 本月你共记录了 X 天。 -- 记录覆盖了:A、B、C。 -- 记录最多的是 XX。 -(根据summary.quality输出对应评价结论) - -📈 **核心指标回顾** - -💪 **体重** - -- 本月整体呈XX趋势,变化幅度 X.Xkg。当前BMI为 XX.X,属于XX范围。 - -❤️ **血压** - -- 本月记录 X次,收缩压均值 XXXmmHg,舒张压均值 XXmmHg,整体处于XX状态。 - -🩸 **血糖** - -- 空腹血糖:共 X次,均值 X.Xmmol/L,在正常范围内。 -- 餐后2h血糖:共 X次,均值 X.Xmmol/L,在正常范围内。 - (存在异常时追加异常提醒语句,无异常不要写) - -🌡️ **体温** - -- 记录 X次,均值 XX℃,整体平稳正常。 - -🏃 **运动** - -- 记录 X次,以XX为主,单次平均 XX分钟。 - -😴 **睡眠** - -- 记录 X次,平均 X.X小时。不足7小时的有 X天。 - -🔗 **交叉洞察** - -- 本月有 X天 同时记录了多个指标。 - -⚠️ **健康关注** - -- 异常内容条目 - -🎯 **下月行动建议** - -- 建议一 -- 建议二 -- 建议三 - -📝 **免责声明** - -本内容由AI生成,仅供参考,不替代专业医疗诊断与治疗。 +## 格式要求 +- 必须是合法的 JSON 格式 +- 所有文本内容中不要包含换行符 \n,用空格代替 +- 不要使用 Markdown 语法 +- 适当使用 Emoji +- 数据用括号简洁说明 +- 风格:温暖、亲和 """ diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..79f3fde --- /dev/null +++ b/config/database.py @@ -0,0 +1,26 @@ +import os + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +load_dotenv() + +DATABASE_URL = f"mysql+pymysql://root:{os.getenv('DB_PASSWORD')}@{os.getenv('DB_HOST')}:3306/health" + +engine = create_engine(url=DATABASE_URL, pool_pre_ping=True, pool_recycle=3600) + +# 会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# ORM基类 +Base = declarative_base() + + +def get_db(): + # 创建数据库会话实例 + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/health.sql b/health.sql new file mode 100644 index 0000000..85a6ee8 --- /dev/null +++ b/health.sql @@ -0,0 +1,77 @@ +-- MySQL dump 10.13 Distrib 8.0.27, for Win64 (x86_64) +-- +-- Host: 127.0.0.1 Database: health +-- ------------------------------------------------------ +-- Server version 8.0.27 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `article` +-- + +DROP TABLE IF EXISTS `article`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `article` ( + `id` bigint NOT NULL, + `date` date NOT NULL COMMENT '日期', + `content` json NOT NULL COMMENT '内容', + `create_time` datetime DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='健康咨询'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `recipe` +-- + +DROP TABLE IF EXISTS `recipe`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `recipe` ( + `id` bigint NOT NULL, + `date` date NOT NULL COMMENT '日期', + `content` json NOT NULL COMMENT '内容', + `create_time` datetime DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='养生食谱'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `report` +-- + +DROP TABLE IF EXISTS `report`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `report` ( + `id` bigint NOT NULL, + `content` json NOT NULL COMMENT '内容', + `create_time` datetime DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='报告'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-09-04 16:57:35 diff --git a/id_generator/__init__.py b/id_generator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/id_generator/generator.py b/id_generator/generator.py new file mode 100644 index 0000000..fc995d7 --- /dev/null +++ b/id_generator/generator.py @@ -0,0 +1,38 @@ +""" +雪花算法生成器IdGenerator +""" + +# !/usr/bin/python +# coding=UTF-8 + + +from . import options +from . import snowflake_m1 + + +class DefaultIdGenerator: + """ + ID生成器 + """ + + def __init__(self): + self.snowflake = None + + def set_id_generator(self, option: options.IdGeneratorOptions): + """ + 设置id生成规则信息 + """ + + if option.base_time < 100000: + raise ValueError("base time error.") + + self.snowflake = snowflake_m1.SnowFlakeM1(option) + + def next_id(self) -> int: + """ + 获取新的UUID + """ + + if self.snowflake is None: + raise ValueError("please set id generator at first.") + return self.snowflake.next_id() diff --git a/id_generator/idregister.py b/id_generator/idregister.py new file mode 100644 index 0000000..787faac --- /dev/null +++ b/id_generator/idregister.py @@ -0,0 +1,134 @@ +""" +worker id generator +""" + +# !/usr/bin/python +# coding=UTF-8 + + +from threading import Thread +import time +import logging +import redis + + +class Register: + """ + redis封装 + - host 代表redis ip + - port 代表redis端口 + - max_worker_id worker_id的最大值, 默认为100 + - password redis的密码, 默认为空 + """ + + def __init__(self, host, port, max_worker_id=100, password=None): + self.redis_impl = redis.StrictRedis(host=host, port=port, db=0, password=password) + self.loop_count = 0 + self.max_loop_count = 10 + self.worker_id_expire_time = 15 + self.max_worker_id = max_worker_id + self.worker_id = -1 + self.is_stop = False + + def get_lock(self, key): + """ + 获取分布式全局锁,并设置过期时间为30秒 + """ + + if self.redis_impl.setnx(key, 1): + self.redis_impl.expire(key, 30) + return True + if self.redis_impl.ttl(key) < 0: + self.redis_impl.expire(key, 30) + return False + + def stop(self): + """ + 退出注册器的线程 + """ + + self.is_stop = True + + def get_worker_id(self): + """ + 获取全局唯一worker_id, 会创建一个线程给worker id续期 + 失败返回-1 + """ + + self.loop_count = 0 + + def extern_life(my_id): + while 1: + time.sleep(self.worker_id_expire_time / 3) + # 是否关闭了 + if self.is_stop: + return + # 更新生命周期 + if self.worker_id != my_id: + break + try: + self.redis_impl.expire( + f"IdGen:WorkerId:Value:{my_id}", + self.worker_id_expire_time) + except Exception as exe: + logging.error(exe) + continue + + self.worker_id = self.__get_next_worker_id() + if self.worker_id > -1: + Thread(target=extern_life, args=[self.worker_id]).start() + return self.worker_id + + def __get_next_worker_id(self): + """ + 获取全局唯一worker id内部实现 + """ + + cur = self.redis_impl.incrby("IdGen:WorkerId:Index", 1) + + def can_reset(): + try: + reset_value = self.redis_impl.incr("IdGen:WorkerId:Value:Edit") + return reset_value != 1 + except Exception as ept: + logging.error(ept) + return False + + def end_reset(): + try: + self.redis_impl.set("IdGen:WorkerId:Value:Edit", 0) + except Exception as ept: + logging.error(ept) + + def is_available(worker_id: int): + try: + rst = self.redis_impl.get(f"IdGen:WorkerId:Value:{worker_id}") + return rst != "Y" + except Exception as ept: + logging.error(ept) + return False + + if cur > self.max_worker_id: + if can_reset(): + self.redis_impl.set("IdGen:WorkerId:Index", -1) + end_reset() + self.loop_count += 1 + + if self.loop_count > self.max_loop_count: + self.loop_count = 0 + return -1 + + time.sleep(0.2 * self.loop_count) + return self.__get_next_worker_id() + time.sleep(0.2) + return self.__get_next_worker_id() + if is_available(cur): + self.redis_impl.setex( + f"IdGen:WorkerId:Value:{cur}", + self.worker_id_expire_time, + "Y" + ) + self.loop_count = 0 + return cur + + return self.__get_next_worker_id() diff --git a/id_generator/options.py b/id_generator/options.py new file mode 100644 index 0000000..8558381 --- /dev/null +++ b/id_generator/options.py @@ -0,0 +1,43 @@ +""" +生成器IdGenerator配置选项 +""" + +# !/usr/bin/python +# coding=UTF-8 + + +class IdGeneratorOptions: + """ + ID生成器配置 + - worker_id 全局唯一id, 区分不同uuid生成器实例 + - worker_id_bit_length 生成的uuid中worker_id占用的位数 + - seq_bit_length 生成的uuid中序列号占用的位数 + """ + + def __init__(self, worker_id=0, worker_id_bit_length=6, seq_bit_length=6): + + # 雪花计算方法,(1-漂移算法|2-传统算法), 默认1。目前只实现了1。 + self.method = 1 + + # 基础时间(ms单位), 不能超过当前系统时间 + self.base_time = 1582136402000 + + # 机器码, 必须由外部设定, 最大值 2^worker_id_bit_length-1 + self.worker_id = worker_id + + # 机器码位长, 默认值6, 取值范围 [1, 15](要求:序列数位长+机器码位长不超过22) + self.worker_id_bit_length = worker_id_bit_length + + # 序列数位长, 默认值6, 取值范围 [3, 21](要求:序列数位长+机器码位长不超过22) + self.seq_bit_length = seq_bit_length + + # 最大序列数(含), 设置范围 [max_seq_number, 2^seq_bit_length-1] + # 默认值0, 表示最大序列数取最大值(2^seq_bit_length-1]) + self.max_seq_number = 0 + + # 最小序列数(含), 默认值5, 取值范围 [5, max_seq_number], 每毫秒的前5个序列数对应编号0-4是保留位 + # 其中1-4是时间回拨相应预留位, 0是手工新值预留位 + self.min_seq_number = 5 + + # 最大漂移次数(含), 默认2000, 推荐范围500-10000(与计算能力有关) + self.top_over_cost_count = 2000 diff --git a/id_generator/snowflake.py b/id_generator/snowflake.py new file mode 100644 index 0000000..ea36cfa --- /dev/null +++ b/id_generator/snowflake.py @@ -0,0 +1,20 @@ + +""" +雪花算法生成器接口声明 +""" + +# !/usr/bin/python +# coding=UTF-8 + + +class SnowFlake(): + + def __init__(self, options): + self.options = options + + def next_id(self) -> int: + """ + 获取新的UUID + """ + + return 0 diff --git a/id_generator/snowflake_m1.py b/id_generator/snowflake_m1.py new file mode 100644 index 0000000..596b76d --- /dev/null +++ b/id_generator/snowflake_m1.py @@ -0,0 +1,147 @@ +""" +M1生成器 +""" + +# !/usr/bin/python +# coding=UTF-8 + +import threading +import time +from .snowflake import SnowFlake +from .options import IdGeneratorOptions + + +class SnowFlakeM1(SnowFlake): + """ + M1规则ID生成器配置 + """ + + def __init__(self, options: IdGeneratorOptions): + # 1.base_time + self.base_time = 1582136402000 + if options.base_time != 0: + self.base_time = int(options.base_time) + + # 2.worker_id_bit_length + self.worker_id_bit_length = 6 + if options.worker_id_bit_length != 0: + self.worker_id_bit_length = int(options.worker_id_bit_length) + + # 3.worker_id + self.worker_id = options.worker_id + + # 4.seq_bit_length + self.seq_bit_length = 6 + if options.seq_bit_length != 0: + self.seq_bit_length = int(options.seq_bit_length) + + # 5.max_seq_number + self.max_seq_number = int(options.max_seq_number) + if options.max_seq_number <= 0: + self.max_seq_number = (1 << self.seq_bit_length) - 1 + + # 6.min_seq_number + self.min_seq_number = int(options.min_seq_number) + + # 7.top_over_cost_count + self.top_over_cost_count = int(options.top_over_cost_count) + + # 8.Others + self.__timestamp_shift = self.worker_id_bit_length + self.seq_bit_length + self.__current_seq_number = self.min_seq_number + self.__last_time_tick: int = 0 + self.__turn_back_time_tick: int = 0 + self.__turn_back_index: int = 0 + self.__is_over_cost = False + self.___over_cost_count_in_one_term: int = 0 + self.__id_lock = threading.Lock() + + def __next_over_cost_id(self) -> int: + current_time_tick = self.__get_current_time_tick() + if current_time_tick > self.__last_time_tick: + self.__last_time_tick = current_time_tick + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = False + self.___over_cost_count_in_one_term = 0 + return self.__calc_id(self.__last_time_tick) + + if self.___over_cost_count_in_one_term >= self.top_over_cost_count: + self.__last_time_tick = self.__get_next_time_tick() + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = False + self.___over_cost_count_in_one_term = 0 + return self.__calc_id(self.__last_time_tick) + + if self.__current_seq_number > self.max_seq_number: + self.__last_time_tick += 1 + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = True + self.___over_cost_count_in_one_term += 1 + return self.__calc_id(self.__last_time_tick) + + return self.__calc_id(self.__last_time_tick) + + def __next_normal_id(self) -> int: + current_time_tick = self.__get_current_time_tick() + if current_time_tick < self.__last_time_tick: + if self.__turn_back_time_tick < 1: + self.__turn_back_time_tick = self.__last_time_tick - 1 + self.__turn_back_index += 1 + # 每毫秒序列数的前5位是预留位, 0用于手工新值, 1-4是时间回拨次序 + # 支持4次回拨次序(避免回拨重叠导致ID重复), 可无限次回拨(次序循环使用)。 + if self.__turn_back_index > 4: + self.__turn_back_index = 1 + + return self.__calc_turn_back_id(self.__turn_back_time_tick) + + # 时间追平时, _TurnBackTimeTick清零 + self.__turn_back_time_tick = min(self.__turn_back_time_tick, 0) + + if current_time_tick > self.__last_time_tick: + self.__last_time_tick = current_time_tick + self.__current_seq_number = self.min_seq_number + return self.__calc_id(self.__last_time_tick) + + if self.__current_seq_number > self.max_seq_number: + self.__last_time_tick += 1 + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = True + self.___over_cost_count_in_one_term = 1 + return self.__calc_id(self.__last_time_tick) + + return self.__calc_id(self.__last_time_tick) + + def __calc_id(self, use_time_tick) -> int: + self.__current_seq_number += 1 + return ( + (use_time_tick << self.__timestamp_shift) + + (self.worker_id << self.seq_bit_length) + + self.__current_seq_number + ) % int(1e64) + + def __calc_turn_back_id(self, use_time_tick) -> int: + self.__turn_back_time_tick -= 1 + return ( + (use_time_tick << self.__timestamp_shift) + + (self.worker_id << self.seq_bit_length) + + self.__turn_back_index + ) % int(1e64) + + def __get_current_time_tick(self) -> int: + return int((time.time_ns() / 1e6) - self.base_time) + + def __get_next_time_tick(self) -> int: + temp_time_ticker = self.__get_current_time_tick() + while temp_time_ticker <= self.__last_time_tick: + # 0.001 = 1 mili sec + time.sleep(0.001) + temp_time_ticker = self.__get_current_time_tick() + return temp_time_ticker + + def next_id(self) -> int: + with self.__id_lock: + if self.__is_over_cost: + nextid = self.__next_over_cost_id() + else: + nextid = self.__next_normal_id() + return nextid diff --git a/main.py b/main.py index 0730e08..be06463 100644 --- a/main.py +++ b/main.py @@ -5,17 +5,18 @@ from apscheduler.schedulers.background import BackgroundScheduler from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware +from config.database import get_db from routers import routers from service import agent_service scheduler = BackgroundScheduler() -def tip_job_task(): +def article_job_task(): """定时执行的任务""" try: print(f"今日资讯定时任务执行: {datetime.now()}") - agent_service.query_tip_agent() + agent_service.query_article_agent(get_db()) except Exception as e: print(f"今日资讯定时任务异常: {e}") @@ -23,12 +24,12 @@ def recipe_job_task(): """定时执行的任务""" try: print(f"今日菜谱定时任务执行: {datetime.now()}") - agent_service.query_recipe_agent() + agent_service.query_recipe_agent(get_db()) except Exception as e: print(f"今日菜谱定时任务异常: {e}") -scheduler.add_job(tip_job_task, trigger="cron", hour=2, minute=0, second=0, id="daily_gen_tips", replace_existing=True) +scheduler.add_job(article_job_task, trigger="cron", hour=2, minute=0, second=0, id="daily_gen_articles", replace_existing=True) scheduler.add_job(recipe_job_task, trigger="cron", hour=3, minute=0, second=0, id="daily_gen_recipes", replace_existing=True) @asynccontextmanager diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..add1ab4 --- /dev/null +++ b/models/base.py @@ -0,0 +1,41 @@ +from sqlalchemy import Column, BigInteger, DateTime, event +from sqlalchemy.ext.declarative import declared_attr + +from config.database import Base +from datetime import datetime + +from utils.common import camel_to_snake +from id_generator import options, generator + +# https://github.com/yitter/IdGenerator/tree/master/Python +options = options.IdGeneratorOptions(worker_id=23) +idgen = generator.DefaultIdGenerator() +idgen.set_id_generator(options) + + +# 第二层基类:包含ID +class IdBase(Base): + __abstract__ = True + + id = Column(BigInteger, primary_key=True, index=True) + + @declared_attr + def __tablename__(cls): + # 自动把数据库实体类名驼峰转为数据库表名下划线 + return camel_to_snake(cls.__name__) + + +# 自动填充id +@event.listens_for(IdBase, 'before_insert', propagate=True) +def before_insert_listener(mapper, connection, target): + if target.id is None: + target.id = idgen.next_id() + + +# 第二层基类:包含ID和审计字段 +class AuditBase(IdBase): + __abstract__ = True + + create_time = Column(DateTime, nullable=True, default=datetime.now) + update_time = Column(DateTime, nullable=True, default=datetime.now, onupdate=datetime.now) + diff --git a/models/health.py b/models/health.py new file mode 100644 index 0000000..da6e9bc --- /dev/null +++ b/models/health.py @@ -0,0 +1,16 @@ +from sqlalchemy import (Column, Date, JSON) + +from models.base import AuditBase + + +class Article(AuditBase): + date = Column(Date, nullable=False, comment="日期") + content = Column(JSON, nullable=False, default=dict, comment="内容") + + +class Recipe(AuditBase): + date = Column(Date, nullable=False, comment="日期") + content = Column(JSON, nullable=False, default=dict, comment="内容") + +class Report(AuditBase): + content = Column(JSON, nullable=False, default=dict, comment="内容") diff --git a/requirements.txt b/requirements.txt index b5a9b21..1717722 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ pydantic~=2.13.4 SQLAlchemy~=2.0.51 asyncpg~=0.30.0 uvicorn~=0.23.0 -APScheduler~=3.11.3 \ No newline at end of file +APScheduler~=3.11.3 +pymysql~=1.2.0 \ No newline at end of file diff --git a/routers/__init__.py b/routers/__init__.py index 18dacad..cde2883 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -1,5 +1,7 @@ from .agent import router as agent_router +from .report import router as report_router routers = [ - agent_router + agent_router, + report_router ] diff --git a/routers/agent.py b/routers/agent.py index 6a4ca17..1734145 100644 --- a/routers/agent.py +++ b/routers/agent.py @@ -1,31 +1,33 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session -from schema.agent import ReportRequest +from config.database import get_db +from schema.agent import ReportAgentRequest from service import agent_service router = APIRouter(prefix="/agent", tags=["Agent"]) -@router.post("/tip/generate") -def generate_tip(): - return agent_service.query_tip_agent() +@router.post("/article/generate") +def generate_article(db: Session = Depends(get_db)): + return agent_service.query_article_agent(db) -@router.get("/tip/latest") -def latest_tip(): - return agent_service.get_latest_tip() +@router.get("/article/latest") +def latest_article(db: Session = Depends(get_db)): + return agent_service.get_latest_article(db) @router.post("/recipe/generate") -def generate_recipe(): - return agent_service.query_recipe_agent() +def generate_recipe(db: Session = Depends(get_db)): + return agent_service.query_recipe_agent(db) @router.get("/recipe/latest") -def latest_recipe(): - return agent_service.get_latest_recipe() +def latest_recipe(db: Session = Depends(get_db)): + return agent_service.get_latest_recipe(db) @router.post("/report") -def query_report(req: ReportRequest): +def query_report(req: ReportAgentRequest): return agent_service.query_report_agent(req) diff --git a/routers/report.py b/routers/report.py new file mode 100644 index 0000000..702d912 --- /dev/null +++ b/routers/report.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from config.database import get_db +from schema.report import ReportRequest +from service import report_service + +router = APIRouter(prefix="/report", tags=["Report"]) + + +@router.get("/{id}") +def query_report(id: int, db: Session = Depends(get_db)): + return report_service.query_report(id, db) + + +@router.post("") +def upload_report(req: ReportRequest, db: Session = Depends(get_db)): + return report_service.upload_report(req.content, db) diff --git a/schema/agent.py b/schema/agent.py index 68a0e3b..40bea89 100644 --- a/schema/agent.py +++ b/schema/agent.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -class ReportRequest(BaseModel): +class ReportAgentRequest(BaseModel): query: str \ No newline at end of file diff --git a/schema/report.py b/schema/report.py new file mode 100644 index 0000000..1592940 --- /dev/null +++ b/schema/report.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class ReportRequest(BaseModel): + content: str diff --git a/service/agent_service.py b/service/agent_service.py index 6d67fb9..8ebe823 100644 --- a/service/agent_service.py +++ b/service/agent_service.py @@ -1,52 +1,62 @@ import json -from datetime import datetime -from typing import Dict, Any +from datetime import datetime, date from langchain_core.messages import HumanMessage, SystemMessage +from sqlalchemy import select, delete +from sqlalchemy.orm import Session -from agent.health import tip_agent, recipe_agent, report_agent -from schema.agent import ReportRequest - -memory_store: Dict[str, Any] = { - "articles": [], - "recipe": [] -} +from agent.health import article_agent, recipe_agent, report_agent +from models.health import Article, Recipe +from schema.agent import ReportAgentRequest -def query_tip_agent(): +def query_article_agent(db: Session): try: print("开始生成今日健康资讯") today = datetime.today().strftime("%Y-%m-%d") - resp = tip_agent.invoke({ + resp = article_agent.invoke({ "messages": [SystemMessage(content=f"当前系统日期:{today} \n\n"), HumanMessage(content="请生成今日健康资讯")] }) last_msg = resp["messages"][-1] raw_text = last_msg.content.strip() - # 解析大模型返回的json字符串 - articles = json.loads(raw_text) - # 写入内存缓存 - memory_store["articles"] = articles + db.execute( + delete(Article).where(Article.date == today) + ) + + article = Article( + date=date.today(), + content=json.loads(raw_text), + ) + db.add(article) + db.commit() + db.refresh(article) + print("结束生成今日健康资讯") return True except json.JSONDecodeError as je: print(f"[JSON解析错误] {str(je)}") - memory_store["articles"] = [] return False except Exception as e: print(f"\n[错误]: {str(e)}") - memory_store["articles"] = [] return False -def get_latest_tip(): - return memory_store["articles"] +def get_latest_article(db: Session): + today = date.today() + + result = db.execute( + select(Article).where(Article.date == today) + ) + article = result.scalar_one_or_none() + + return article -def query_recipe_agent(): +def query_recipe_agent(db: Session): try: print("开始生成今日养生食谱") today = datetime.today().strftime("%Y-%m-%d") @@ -58,28 +68,41 @@ def query_recipe_agent(): last_msg = resp["messages"][-1] raw_text = last_msg.content.strip() - # 解析大模型返回的json字符串 - recipes = json.loads(raw_text) - # 写入内存缓存 - memory_store["recipe"] = recipes + db.execute( + delete(Recipe).where(Recipe.date == today) + ) + + recipe = Recipe( + date=date.today(), + content=json.loads(raw_text), + ) + db.add(recipe) + db.commit() + db.refresh(recipe) + print("结束生成今日养生食谱") return True except json.JSONDecodeError as je: print(f"[JSON解析错误] {str(je)}") - memory_store["recipe"] = [] return False except Exception as e: print(f"\n[错误]: {str(e)}") - memory_store["recipe"] = [] return False -def get_latest_recipe(): - return memory_store["recipe"] +def get_latest_recipe(db: Session): + today = date.today() + + result = db.execute( + select(Recipe).where(Recipe.date == today) + ) + article = result.scalar_one_or_none() + + return article -def query_report_agent(req: ReportRequest): +def query_report_agent(req: ReportAgentRequest): try: print("开始生成报告") today = datetime.today().strftime("%Y-%m-%d") @@ -93,4 +116,4 @@ def query_report_agent(req: ReportRequest): return last_msg.content.strip() except Exception as e: print(f"\n[生成报告错误]: {str(e)}") - return False \ No newline at end of file + return False diff --git a/service/report_service.py b/service/report_service.py new file mode 100644 index 0000000..a41e6a3 --- /dev/null +++ b/service/report_service.py @@ -0,0 +1,24 @@ +import json + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.health import Report + + +def upload_report(report: str, db: Session): + report = Report( + content=json.loads(report), + ) + db.add(report) + db.commit() + db.refresh(report) + + return True + + +def query_report(id: int, db: Session): + result = db.execute( + select(Report).where(Report.id == id) + ) + return result.scalar_one_or_none() diff --git a/utils/common.py b/utils/common.py new file mode 100644 index 0000000..bb599fe --- /dev/null +++ b/utils/common.py @@ -0,0 +1,13 @@ +import re + + +def camel_to_snake(name: str) -> str: + """将驼峰命名转换为蛇形命名(CamelCase → snake_case)""" + name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + + +def snake_to_camel(name: str) -> str: + """将蛇形命名转换为驼峰命名(snake_case → CamelCase)""" + components = name.split('_') + return ''.join(x.title() for x in components)