diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0650911 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = 'zhiyi' +version = '0.1.0' +description = '织忆 MemoryWeave - 独立记忆服务' +requires-python = '>=3.10' +dependencies = ['fastapi', 'uvicorn'] + +[project.scripts] +zhiyi = 'api.server:main' \ No newline at end of file diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/routes/admin.py b/src/api/routes/admin.py new file mode 100644 index 0000000..0148deb --- /dev/null +++ b/src/api/routes/admin.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'src')) + +from storage.jsonl_store import JSONLShardStore +from datetime import datetime + +router = APIRouter() + +@router.get('/stats') +def get_stats(): + store = JSONLShardStore() + episode_stats = store.get_stats('episodes') + distilled_stats = store.get_stats('distilled') + return { + 'episodes': episode_stats, + 'distilled': distilled_stats, + 'timestamp': datetime.now().isoformat() + } + +@router.get('/config') +def get_config(): + return { + 'decay_rate': 0.015, + 'decay_floor': 0.1, + 'distill_threshold': 0.7, + 'version': '2.6' + } \ No newline at end of file diff --git a/src/api/routes/commit.py b/src/api/routes/commit.py new file mode 100644 index 0000000..2e76d64 --- /dev/null +++ b/src/api/routes/commit.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, List +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'src')) + +from models.episode import Episode +from distill.engine import DistillEngine +from storage.jsonl_store import JSONLShardStore + +router = APIRouter() +_store = JSONLShardStore() +_engine = DistillEngine() + +class CommitRequest(BaseModel): + content: str + source: str = 'hermes' + entities: List[str] = [] + facts: List[str] = [] + metadata: dict = {} + +class BatchCommitRequest(BaseModel): + records: List[CommitRequest] + +@router.post('/commit') +def commit_memory(req: CommitRequest): + episode = Episode.create( + content=req.content, + source=req.source, + entities=req.entities, + facts=req.facts, + metadata=req.metadata + ) + _store.append('episodes', episode.to_dict()) + _engine.queue.enqueue(episode) + return {'status': 'ok', 'episode_id': episode.id} + +@router.post('/batch-commit') +def batch_commit(req: BatchCommitRequest): + results = [] + for r in req.records: + episode = Episode.create(content=r.content, source=r.source, + entities=r.entities, facts=r.facts, + metadata=r.metadata) + _store.append('episodes', episode.to_dict()) + _engine.queue.enqueue(episode) + results.append({'episode_id': episode.id}) + return {'status': 'ok', 'committed': len(results)} \ No newline at end of file diff --git a/src/api/routes/conflicts.py b/src/api/routes/conflicts.py new file mode 100644 index 0000000..668abd3 --- /dev/null +++ b/src/api/routes/conflicts.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, List +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'src')) + +from distill.conflicts import ConflictDetector, ConflictType, ConflictStrategy +from storage.jsonl_store import JSONLShardStore +from datetime import datetime + +router = APIRouter() +_detector = ConflictDetector() +_store = JSONLShardStore() +_conflicts_store: List[dict] = [] + +class ResolveRequest(BaseModel): + conflict_id: str + resolution: dict + +@router.get('/conflicts') +def list_conflicts(): + return {'conflicts': _conflicts_store, 'count': len(_conflicts_store)} + +@router.post('/conflicts/scan') +def scan_conflicts(): + now = datetime.now() + distilled_records = list(_store.read_month('distilled', now.year, now.month)) + + class FakeDistilled: + def __init__(self, d): + self.id = d.get('id', '') + self.entities = d.get('entities', []) + self.facts = d.get('facts', []) + + all_conflicts = [] + for record in distilled_records: + fd = FakeDistilled(record) + conflicts = _detector.scan_conflicts(fd, distilled_records) + for c in conflicts: + c_dict = { + 'id': c.id, + 'type': c.type.value, + 'entity': c.entity, + 'entries': c.entries, + 'strategy': c.strategy.value, + 'status': c.status, + 'created_at': c.created_at.isoformat() + } + if not any(x['id'] == c_dict['id'] for x in _conflicts_store): + _conflicts_store.append(c_dict) + all_conflicts.append(c_dict) + + return {'conflicts': all_conflicts, 'count': len(all_conflicts)} + +@router.post('/conflicts/resolve') +def resolve_conflict(req: ResolveRequest): + for c in _conflicts_store: + if c['id'] == req.conflict_id: + c['status'] = 'resolved' + c['resolution'] = req.resolution + return {'status': 'ok', 'conflict_id': req.conflict_id} + raise HTTPException(status_code=404, detail='Conflict not found') \ No newline at end of file diff --git a/src/api/routes/feedback.py b/src/api/routes/feedback.py new file mode 100644 index 0000000..1485050 --- /dev/null +++ b/src/api/routes/feedback.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'src')) + +from storage.tombstone import TombstoneStore + +router = APIRouter() + +class ValidateRequest(BaseModel): + distilled_id: str + namespace: str = 'zhiyi' + importance: Optional[int] = None + +class CorrectRequest(BaseModel): + distilled_id: str + correction: dict + reason: str = '' + +class DeprecateRequest(BaseModel): + distilled_id: str + reason: str = '' + suggested_replacement: Optional[str] = None + +@router.post('/feedback/validate') +def validate_memory(req: ValidateRequest): + return {'status': 'ok', 'distilled_id': req.distilled_id, 'importance': req.importance} + +@router.post('/feedback/correct') +def correct_memory(req: CorrectRequest): + return {'status': 'ok', 'distilled_id': req.distilled_id, 'correction': req.correction} + +@router.post('/feedback/deprecate') +def deprecate_memory(req: DeprecateRequest): + t = TombstoneStore() + t.mark_deleted(req.distilled_id, reason=req.reason) + return {'status': 'ok', 'distilled_id': req.distilled_id} \ No newline at end of file diff --git a/src/api/routes/recall.py b/src/api/routes/recall.py new file mode 100644 index 0000000..1e7211a --- /dev/null +++ b/src/api/routes/recall.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'src')) + +from storage.jsonl_store import JSONLShardStore +from datetime import datetime + +router = APIRouter() +_store = JSONLShardStore() + +class RecallRequest(BaseModel): + query: str + limit: int = 10 + namespace: Optional[str] = 'zhiyi' + start_time: Optional[str] = None + end_time: Optional[str] = None + +@router.post('/recall') +def recall_memory(req: RecallRequest): + start = datetime.fromisoformat(req.start_time) if req.start_time else None + end = datetime.fromisoformat(req.end_time) if req.end_time else None + + results = [] + if start and end: + records = _store.query_by_timerange('distilled', start, end) + else: + now = datetime.now() + records = _store.read_month('distilled', now.year, now.month) + + query_lower = req.query.lower() + for record in records: + content = str(record.get('summary', '')) + ' ' + ' '.join(str(f) for f in record.get('facts', [])) + if query_lower in content.lower(): + results.append(record) + if len(results) >= req.limit: + break + + return {'results': results, 'count': len(results), 'query': req.query} + +@router.post('/recall/batch') +def recall_batch(requests: List[RecallRequest]): + return {'results': [recall_memory(r) for r in requests]} \ No newline at end of file diff --git a/src/api/server.py b/src/api/server.py new file mode 100644 index 0000000..598bddb --- /dev/null +++ b/src/api/server.py @@ -0,0 +1,38 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pathlib import Path +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +app = FastAPI(title='织忆 MemoryWeave API', version='2.6') + +app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) + +from api.routes import commit, recall, conflicts, feedback, admin + +app.include_router(commit.router, prefix='/api/v1', tags=['commit']) +app.include_router(recall.router, prefix='/api/v1', tags=['recall']) +app.include_router(conflicts.router, prefix='/api/v1', tags=['conflicts']) +app.include_router(feedback.router, prefix='/api/v1', tags=['feedback']) +app.include_router(admin.router, prefix='/api/v1', tags=['admin']) + +@app.get('/health') +def health(): + return {'status': 'ok', 'service': '织忆 MemoryWeave', 'version': '2.6'} + +@app.get('/') +def root(): + return {'service': '织忆 MemoryWeave', 'version': '2.6', 'docs': '/docs'} + +def main(): + import uvicorn + uvicorn.run(app, host='0.0.0.0', port=7821) + +if __name__ == '__main__': + main() \ No newline at end of file