Files
mi-fitness-health/qa/serve_query_fixture.py
T

140 lines
6.9 KiB
Python

"""仅本机合成数据入口;不加载用户配置、凭据或同步适配器。"""
import argparse
import json
import os
import sqlite3
import threading
import time
from contextlib import asynccontextmanager, contextmanager
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from mi_fitness_mcp.storage import Database
ROOT = Path(__file__).resolve().parents[1]
KINDS = ('activity', 'heartrate', 'sleep', 'workouts', 'body', 'spo2', 'stress', 'abnormal')
OWNER = 'synthetic-owner'
KEY = 'synthetic-query-key'
PASSWORD = 'synthetic-web-password'
def artifact_path(value):
path = Path(value).resolve()
if not path.is_relative_to(ROOT / 'artifacts'):
raise ValueError('合成数据和输出必须位于本仓库 artifacts 目录')
return path
def generate(path, density=1):
path = artifact_path(path)
if path.exists():
raise FileExistsError('拒绝覆盖已有文件:' + str(path))
db = Database(path)
tz = timezone(timedelta(hours=8))
with db._get_connection() as conn:
def insert(table, rows):
fields = ('id', 'provider', 'source_type', 'user_id') + tuple(rows[0])
conn.executemany(f"INSERT INTO {table} ({','.join(fields)}) VALUES ({','.join('?' for _ in fields)})",
[(f'{table}-{offset:08}', 'synthetic', 'generated', OWNER, *row.values()) for offset, row in enumerate(rows)])
for table, count, field in [('heart_rate_samples', 3000, 'bpm'), ('spo2_samples', 1000, 'spo2_pct'), ('stress_samples', 1000, 'stress_score')]:
rows = []
for day in range(30):
base = datetime(2026, 9, 18, tzinfo=tz) + timedelta(days=day)
for n in range(count * density):
stamp = (base + timedelta(seconds=n * 86400 / (count * density))).isoformat()
row = dict(timestamp=stamp)
row[field] = 55 + n % 70 if field == 'bpm' else 92 + n % 9 if field == 'spo2_pct' else n % 102
if field == 'bpm':
row['sample_type'] = ('passive', 'resting', 'active')[n % 3]
if field == 'stress_score':
row['level'] = 'synthetic-source'
rows.append(row)
insert(table, rows)
for table in ('daily_activity', 'sleep_sessions', 'workouts', 'body_measurements', 'abnormal_heart_beat_events'):
rows = []
for day in range(30):
d = date(2026, 9, 18) + timedelta(days=day)
stamp = d.isoformat() + 'T08:00:00+08:00'
for n in range(2):
if table == 'daily_activity':
row = dict(date=d.isoformat(), device_id=f'device-{n}', steps=2000 + day * 10 + n, distance_m=1800 + day, active_kcal=100 + n, total_kcal=1000 + day)
elif table == 'sleep_sessions':
row = dict(sleep_id=f's-{day}-{n}', start_at=(d - timedelta(days=1)).isoformat() + 'T23:00:00+08:00', end_at=stamp, duration_minutes=495 if n == 0 else 30, time_asleep_minutes=480 if n == 0 else 30, time_awake_minutes=15 if n == 0 else 0, is_nap=n, stages=json.dumps([dict(stage='deep', minutes=120), dict(stage='light', minutes=280), dict(stage='rem', minutes=80)]) if n == 0 else None)
elif table == 'workouts':
row = dict(workout_id=f'w-{day}-{n}', activity_type='run', start_at=stamp, end_at=d.isoformat() + 'T09:00:00+08:00', duration_minutes=60, distance_m=8000, calories_kcal=400)
elif table == 'body_measurements':
row = dict(timestamp=stamp, device_id=f'device-{n}', weight_kg=70 + day / 10 + n / 100, bmi=22, body_fat_pct=20)
else:
row = dict(event_id=f'a-{day}-{n}', start_at=stamp, end_at=d.isoformat() + 'T08:01:00+08:00', duration_seconds=60)
rows.append(row)
insert(table, rows)
conn.execute('CREATE TABLE api_keys (key TEXT PRIMARY KEY,user_id TEXT,pass_token TEXT,region TEXT,revoked INTEGER DEFAULT 0)')
conn.executemany('INSERT INTO api_keys VALUES (?,?,?,?,0)', [(KEY, OWNER, '', 'cn'), ('synthetic-other-key', 'synthetic-other', '', 'cn')])
conn.commit()
return path
class ReadOnlyDatabase(Database):
def __init__(self, path):
self.db_path = artifact_path(path)
if not self.db_path.is_file():
raise FileNotFoundError(self.db_path)
@contextmanager
def _get_connection(self):
conn = sqlite3.connect(self.db_path.as_uri() + '?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def backend(path):
# 替换 lifespan 只为隔离配置和密钥环;路由、鉴权、查询服务使用产品实现。
from mi_fitness_mcp import api
from mi_fitness_mcp.services.query_service import QueryService
db = ReadOnlyDatabase(path)
@asynccontextmanager
async def lifespan(app):
app.state.db = db
app.state.default_context = None
app.state.key_contexts = {}
app.state.key_context_lock = threading.RLock()
for key, owner in [(KEY, OWNER), ('synthetic-other-key', 'synthetic-other')]:
ctx = api.UserContext(owner, 'synthetic-unused', 'cn', query_service=QueryService(db, owner))
app.state.key_contexts[key] = dict(expires=time.monotonic() + 86400, identity=(owner, 'cn', ''), context=ctx)
yield
api.app.router.lifespan_context = lifespan
return api.app
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['generate', 'backend', 'web'])
parser.add_argument('--db', default=str(ROOT / 'artifacts/query-summary/synthetic-1x.db'))
parser.add_argument('--density', type=int, choices=[1, 10], default=1)
parser.add_argument('--port', type=int, default=18431)
parser.add_argument('--backend-url', default='http://127.0.0.1:18431')
parser.add_argument('--server-key', action='store_true')
parser.add_argument('--host', default='127.0.0.1', choices=['127.0.0.1', '0.0.0.0'])
args = parser.parse_args()
for name in ('MI_FITNESS_API_KEY', 'MI_FITNESS_ADMIN_KEY'):
os.environ.pop(name, None)
os.environ['MI_FITNESS_ALLOWED_HOSTS'] = '127.0.0.1,localhost,backend,web'
os.environ['MI_FITNESS_WEB_PASSWORD'] = PASSWORD
os.environ['MI_FITNESS_CALENDAR_DIR'] = str(ROOT / 'artifacts/query-summary/calendar')
if args.mode == 'generate':
print(generate(args.db, args.density))
elif args.mode == 'backend':
import uvicorn
uvicorn.run(backend(args.db), host=args.host, port=args.port, access_log=False)
else:
from mi_fitness_mcp.web import create_app
app = create_app(backend_url=args.backend_url, api_key=KEY if args.server_key else None)
app.run(host=args.host, port=args.port, debug=False, use_reloader=False)
if __name__ == '__main__':
main()