mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
qa: validate server queries and prepare offline backend web update
This commit is contained in:
1 parent
6292755ff9
commit
c076bbc0d6
11 files changed
+640
-1
No files matched your search
@@ -0,0 +1,139 @@
|
||||
"""仅本机合成数据入口;不加载用户配置、凭据或同步适配器。"""
|
||||
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()
|
||||
Reference in new issue
Block a user