@purpleraven/hits 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +298 -0
- package/LICENSE +190 -0
- package/README.md +336 -0
- package/bin/hits.js +56 -0
- package/config/schema.json +94 -0
- package/config/settings.yaml +102 -0
- package/data/dev_handover.yaml +143 -0
- package/hits_core/__init__.py +9 -0
- package/hits_core/ai/__init__.py +11 -0
- package/hits_core/ai/compressor.py +86 -0
- package/hits_core/ai/llm_client.py +65 -0
- package/hits_core/ai/slm_filter.py +126 -0
- package/hits_core/api/__init__.py +3 -0
- package/hits_core/api/routes/__init__.py +8 -0
- package/hits_core/api/routes/auth.py +211 -0
- package/hits_core/api/routes/handover.py +117 -0
- package/hits_core/api/routes/health.py +8 -0
- package/hits_core/api/routes/knowledge.py +177 -0
- package/hits_core/api/routes/node.py +121 -0
- package/hits_core/api/routes/work_log.py +174 -0
- package/hits_core/api/server.py +181 -0
- package/hits_core/auth/__init__.py +21 -0
- package/hits_core/auth/dependencies.py +61 -0
- package/hits_core/auth/manager.py +368 -0
- package/hits_core/auth/middleware.py +69 -0
- package/hits_core/collector/__init__.py +18 -0
- package/hits_core/collector/ai_session_collector.py +118 -0
- package/hits_core/collector/base.py +73 -0
- package/hits_core/collector/daemon.py +94 -0
- package/hits_core/collector/git_collector.py +177 -0
- package/hits_core/collector/hits_action_collector.py +110 -0
- package/hits_core/collector/shell_collector.py +178 -0
- package/hits_core/main.py +36 -0
- package/hits_core/mcp/__init__.py +20 -0
- package/hits_core/mcp/server.py +429 -0
- package/hits_core/models/__init__.py +18 -0
- package/hits_core/models/node.py +56 -0
- package/hits_core/models/tree.py +68 -0
- package/hits_core/models/work_log.py +64 -0
- package/hits_core/models/workflow.py +92 -0
- package/hits_core/platform/__init__.py +5 -0
- package/hits_core/platform/actions.py +225 -0
- package/hits_core/service/__init__.py +6 -0
- package/hits_core/service/handover_service.py +382 -0
- package/hits_core/service/knowledge_service.py +172 -0
- package/hits_core/service/tree_service.py +105 -0
- package/hits_core/storage/__init__.py +11 -0
- package/hits_core/storage/base.py +84 -0
- package/hits_core/storage/file_store.py +314 -0
- package/hits_core/storage/redis_store.py +123 -0
- package/hits_web/dist/assets/index-Bgx7F6m6.css +1 -0
- package/hits_web/dist/assets/index-D1B5E67G.js +3 -0
- package/hits_web/dist/index.html +16 -0
- package/package.json +60 -0
- package/requirements-core.txt +7 -0
- package/requirements.txt +1 -0
- package/run.sh +271 -0
- package/server.js +234 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Handover API routes - project-scoped session handover endpoints.
|
|
2
|
+
|
|
3
|
+
These endpoints enable AI tools to:
|
|
4
|
+
1. Get a structured handover summary for a specific project
|
|
5
|
+
2. List all projects with activity
|
|
6
|
+
3. Get project statistics
|
|
7
|
+
|
|
8
|
+
Typical flow:
|
|
9
|
+
Session ending → POST /api/work-log (record work)
|
|
10
|
+
Session starting → GET /api/handover?project_path=... (get context)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, Query
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from hits_core.service.handover_service import HandoverService
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
_service: Optional[HandoverService] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_service() -> HandoverService:
|
|
28
|
+
global _service
|
|
29
|
+
if _service is None:
|
|
30
|
+
_service = HandoverService()
|
|
31
|
+
return _service
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APIResponse(BaseModel):
|
|
35
|
+
success: bool
|
|
36
|
+
data: Optional[Any] = None
|
|
37
|
+
error: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/handover", response_model=APIResponse)
|
|
41
|
+
async def get_handover(
|
|
42
|
+
project_path: str = Query(..., description="Absolute path to the project directory"),
|
|
43
|
+
format: str = Query("dict", description="Output format: 'dict' (JSON) or 'text' (markdown)"),
|
|
44
|
+
recent_count: int = Query(20, ge=1, le=100, description="Number of recent logs to include"),
|
|
45
|
+
):
|
|
46
|
+
"""Get a handover summary for a specific project.
|
|
47
|
+
|
|
48
|
+
This is the primary endpoint for session continuity. When an AI tool
|
|
49
|
+
starts a new session on a project, it calls this endpoint to get
|
|
50
|
+
all relevant context from previous sessions.
|
|
51
|
+
|
|
52
|
+
The project_path is used to scope all data to the specific project,
|
|
53
|
+
so different projects get completely independent handovers.
|
|
54
|
+
"""
|
|
55
|
+
service = get_service()
|
|
56
|
+
|
|
57
|
+
# Validate path exists
|
|
58
|
+
if not Path(project_path).exists():
|
|
59
|
+
return APIResponse(
|
|
60
|
+
success=False,
|
|
61
|
+
error=f"Project path does not exist: {project_path}",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
summary = await service.get_handover(
|
|
66
|
+
project_path=project_path,
|
|
67
|
+
recent_count=recent_count,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if format == "text":
|
|
71
|
+
return APIResponse(
|
|
72
|
+
success=True,
|
|
73
|
+
data={"text": summary.to_text()},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return APIResponse(success=True, data=summary.to_dict())
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return APIResponse(success=False, error=str(e))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/handover/projects", response_model=APIResponse)
|
|
83
|
+
async def list_projects():
|
|
84
|
+
"""List all projects that have recorded work logs.
|
|
85
|
+
|
|
86
|
+
Returns project paths with aggregated statistics, sorted by last activity.
|
|
87
|
+
Useful for discovering which projects have accumulated context.
|
|
88
|
+
"""
|
|
89
|
+
service = get_service()
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
projects = await service.list_projects()
|
|
93
|
+
return APIResponse(success=True, data=projects)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return APIResponse(success=False, error=str(e))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("/handover/project-stats", response_model=APIResponse)
|
|
99
|
+
async def get_project_stats(
|
|
100
|
+
project_path: str = Query(..., description="Absolute path to the project directory"),
|
|
101
|
+
):
|
|
102
|
+
"""Get aggregated statistics for a specific project.
|
|
103
|
+
|
|
104
|
+
Returns counts, tags, performers, files modified, etc.
|
|
105
|
+
Lighter than full handover - use this for quick project overview.
|
|
106
|
+
"""
|
|
107
|
+
service = get_service()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
from hits_core.storage.file_store import FileStorage
|
|
111
|
+
storage = FileStorage()
|
|
112
|
+
stats = await storage.get_project_summary(
|
|
113
|
+
str(Path(project_path).resolve())
|
|
114
|
+
)
|
|
115
|
+
return APIResponse(success=True, data=stats)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return APIResponse(success=False, error=str(e))
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Knowledge category API routes.
|
|
2
|
+
|
|
3
|
+
These routes expose the KnowledgeService (config-based categories)
|
|
4
|
+
through the API, enabling the web UI to manage knowledge categories
|
|
5
|
+
and their nodes per-project.
|
|
6
|
+
|
|
7
|
+
Endpoints:
|
|
8
|
+
- GET /api/knowledge/categories → List all categories with nodes
|
|
9
|
+
- POST /api/knowledge/category → Create a new category
|
|
10
|
+
- PUT /api/knowledge/category/{name} → Update a category
|
|
11
|
+
- DELETE /api/knowledge/category/{name} → Delete a category
|
|
12
|
+
- POST /api/knowledge/category/{name}/nodes → Add node
|
|
13
|
+
- PUT /api/knowledge/category/{name}/nodes/{idx} → Update node
|
|
14
|
+
- DELETE /api/knowledge/category/{name}/nodes/{idx} → Delete node
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from hits_core.service.knowledge_service import KnowledgeService, KnowledgeNode
|
|
23
|
+
from hits_core.auth.dependencies import require_auth
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
router = APIRouter()
|
|
27
|
+
|
|
28
|
+
_service: Optional[KnowledgeService] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_service() -> KnowledgeService:
|
|
32
|
+
global _service
|
|
33
|
+
if _service is None:
|
|
34
|
+
_service = KnowledgeService()
|
|
35
|
+
return _service
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class APIResponse(BaseModel):
|
|
39
|
+
success: bool
|
|
40
|
+
data: Optional[Any] = None
|
|
41
|
+
error: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- Models ---
|
|
45
|
+
|
|
46
|
+
class CategoryCreate(BaseModel):
|
|
47
|
+
name: str = Field(..., min_length=1, max_length=100)
|
|
48
|
+
icon: str = Field("📁", max_length=4)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CategoryUpdate(BaseModel):
|
|
52
|
+
name: str = Field(..., min_length=1, max_length=100)
|
|
53
|
+
icon: Optional[str] = Field(None, max_length=4)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NodeCreate(BaseModel):
|
|
57
|
+
name: str = Field(..., min_length=1, max_length=200)
|
|
58
|
+
layer: str = Field("what", pattern=r"^(why|how|what)$")
|
|
59
|
+
type: str = Field("url", pattern=r"^(url|shell)$")
|
|
60
|
+
action: str = Field("")
|
|
61
|
+
negative_path: bool = Field(False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NodeUpdate(BaseModel):
|
|
65
|
+
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
|
66
|
+
layer: Optional[str] = Field(None, pattern=r"^(why|how|what)$")
|
|
67
|
+
type: Optional[str] = Field(None, pattern=r"^(url|shell)$")
|
|
68
|
+
action: Optional[str] = None
|
|
69
|
+
negative_path: Optional[bool] = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- Endpoints ---
|
|
73
|
+
|
|
74
|
+
@router.get("/knowledge/categories", response_model=APIResponse)
|
|
75
|
+
async def list_categories(user: dict = Depends(require_auth)):
|
|
76
|
+
"""List all knowledge categories with their nodes."""
|
|
77
|
+
service = get_service()
|
|
78
|
+
categories = service.list_categories()
|
|
79
|
+
return APIResponse(
|
|
80
|
+
success=True,
|
|
81
|
+
data=[cat.to_dict() for cat in categories],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/knowledge/category", response_model=APIResponse)
|
|
86
|
+
async def create_category(body: CategoryCreate, user: dict = Depends(require_auth)):
|
|
87
|
+
"""Create a new knowledge category."""
|
|
88
|
+
service = get_service()
|
|
89
|
+
cat = service.add_category(body.name, body.icon)
|
|
90
|
+
if cat is None:
|
|
91
|
+
return APIResponse(success=False, error="Category already exists")
|
|
92
|
+
return APIResponse(success=True, data=cat.to_dict())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.put("/knowledge/category/{category_name}", response_model=APIResponse)
|
|
96
|
+
async def update_category(
|
|
97
|
+
category_name: str,
|
|
98
|
+
body: CategoryUpdate,
|
|
99
|
+
user: dict = Depends(require_auth),
|
|
100
|
+
):
|
|
101
|
+
"""Update a category's name and/or icon."""
|
|
102
|
+
service = get_service()
|
|
103
|
+
success = service.update_category(category_name, body.name, body.icon)
|
|
104
|
+
if not success:
|
|
105
|
+
return APIResponse(success=False, error="Category not found")
|
|
106
|
+
return APIResponse(success=True, data={"name": body.name})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.delete("/knowledge/category/{category_name}", response_model=APIResponse)
|
|
110
|
+
async def delete_category(category_name: str, user: dict = Depends(require_auth)):
|
|
111
|
+
"""Delete a category and all its nodes."""
|
|
112
|
+
service = get_service()
|
|
113
|
+
success = service.delete_category(category_name)
|
|
114
|
+
if not success:
|
|
115
|
+
return APIResponse(success=False, error="Category not found")
|
|
116
|
+
return APIResponse(success=True, data={"deleted": category_name})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.post("/knowledge/category/{category_name}/nodes", response_model=APIResponse)
|
|
120
|
+
async def add_node(
|
|
121
|
+
category_name: str,
|
|
122
|
+
body: NodeCreate,
|
|
123
|
+
user: dict = Depends(require_auth),
|
|
124
|
+
):
|
|
125
|
+
"""Add a node to a category."""
|
|
126
|
+
service = get_service()
|
|
127
|
+
node = KnowledgeNode(
|
|
128
|
+
name=body.name,
|
|
129
|
+
layer=body.layer,
|
|
130
|
+
type=body.type,
|
|
131
|
+
action=body.action,
|
|
132
|
+
negative_path=body.negative_path,
|
|
133
|
+
)
|
|
134
|
+
success = service.add_node(category_name, node)
|
|
135
|
+
if not success:
|
|
136
|
+
return APIResponse(success=False, error="Category not found")
|
|
137
|
+
return APIResponse(success=True, data=node.to_dict())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.put("/knowledge/category/{category_name}/nodes/{node_index}", response_model=APIResponse)
|
|
141
|
+
async def update_node(
|
|
142
|
+
category_name: str,
|
|
143
|
+
node_index: int,
|
|
144
|
+
body: NodeUpdate,
|
|
145
|
+
user: dict = Depends(require_auth),
|
|
146
|
+
):
|
|
147
|
+
"""Update a specific node in a category."""
|
|
148
|
+
service = get_service()
|
|
149
|
+
existing = service.get_node(category_name, node_index)
|
|
150
|
+
if existing is None:
|
|
151
|
+
return APIResponse(success=False, error="Node not found")
|
|
152
|
+
|
|
153
|
+
updated = KnowledgeNode(
|
|
154
|
+
name=body.name or existing.name,
|
|
155
|
+
layer=body.layer or existing.layer,
|
|
156
|
+
type=body.type or existing.type,
|
|
157
|
+
action=body.action if body.action is not None else existing.action,
|
|
158
|
+
negative_path=body.negative_path if body.negative_path is not None else existing.negative_path,
|
|
159
|
+
)
|
|
160
|
+
success = service.update_node(category_name, node_index, updated)
|
|
161
|
+
if not success:
|
|
162
|
+
return APIResponse(success=False, error="Failed to update node")
|
|
163
|
+
return APIResponse(success=True, data=updated.to_dict())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.delete("/knowledge/category/{category_name}/nodes/{node_index}", response_model=APIResponse)
|
|
167
|
+
async def delete_node(
|
|
168
|
+
category_name: str,
|
|
169
|
+
node_index: int,
|
|
170
|
+
user: dict = Depends(require_auth),
|
|
171
|
+
):
|
|
172
|
+
"""Delete a node from a category."""
|
|
173
|
+
service = get_service()
|
|
174
|
+
success = service.delete_node(category_name, node_index)
|
|
175
|
+
if not success:
|
|
176
|
+
return APIResponse(success=False, error="Node not found")
|
|
177
|
+
return APIResponse(success=True, data={"deleted_index": node_index})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Query, HTTPException
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from hits_core.service.tree_service import TreeService
|
|
8
|
+
from hits_core.models.node import Node, NodeLayer, NodeType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
_service: Optional[TreeService] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_service() -> TreeService:
|
|
17
|
+
global _service
|
|
18
|
+
if _service is None:
|
|
19
|
+
_service = TreeService()
|
|
20
|
+
return _service
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NodeCreate(BaseModel):
|
|
24
|
+
tree_id: str
|
|
25
|
+
layer: str
|
|
26
|
+
title: str
|
|
27
|
+
description: Optional[str] = None
|
|
28
|
+
node_type: Optional[str] = "standard"
|
|
29
|
+
parent_id: Optional[str] = None
|
|
30
|
+
action: Optional[str] = None
|
|
31
|
+
action_type: Optional[str] = None
|
|
32
|
+
metadata: Optional[dict] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NodeUpdate(BaseModel):
|
|
36
|
+
title: Optional[str] = None
|
|
37
|
+
description: Optional[str] = None
|
|
38
|
+
node_type: Optional[str] = None
|
|
39
|
+
action: Optional[str] = None
|
|
40
|
+
action_type: Optional[str] = None
|
|
41
|
+
metadata: Optional[dict] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class APIResponse(BaseModel):
|
|
45
|
+
success: bool
|
|
46
|
+
data: Optional[Any] = None
|
|
47
|
+
error: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.post("/node", response_model=APIResponse)
|
|
51
|
+
async def create_node(body: NodeCreate):
|
|
52
|
+
service = get_service()
|
|
53
|
+
|
|
54
|
+
tree = await service.get_tree(body.tree_id)
|
|
55
|
+
if tree is None:
|
|
56
|
+
return APIResponse(success=False, error="Tree not found")
|
|
57
|
+
|
|
58
|
+
node = Node(
|
|
59
|
+
id=str(uuid4())[:8],
|
|
60
|
+
layer=NodeLayer(body.layer),
|
|
61
|
+
title=body.title,
|
|
62
|
+
description=body.description,
|
|
63
|
+
node_type=NodeType(body.node_type or "standard"),
|
|
64
|
+
parent_id=body.parent_id,
|
|
65
|
+
action=body.action,
|
|
66
|
+
action_type=body.action_type,
|
|
67
|
+
metadata=body.metadata or {},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
success = await service.add_node(body.tree_id, node)
|
|
71
|
+
if not success:
|
|
72
|
+
return APIResponse(success=False, error="Failed to add node")
|
|
73
|
+
|
|
74
|
+
return APIResponse(success=True, data=node.model_dump())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.put("/node/{node_id}", response_model=APIResponse)
|
|
78
|
+
async def update_node(
|
|
79
|
+
node_id: str,
|
|
80
|
+
tree_id: str = Query(...),
|
|
81
|
+
body: NodeUpdate = None,
|
|
82
|
+
):
|
|
83
|
+
service = get_service()
|
|
84
|
+
|
|
85
|
+
node = await service.get_node(tree_id, node_id)
|
|
86
|
+
if node is None:
|
|
87
|
+
return APIResponse(success=False, error="Node not found")
|
|
88
|
+
|
|
89
|
+
if body.title is not None:
|
|
90
|
+
node.title = body.title
|
|
91
|
+
if body.description is not None:
|
|
92
|
+
node.description = body.description
|
|
93
|
+
if body.node_type is not None:
|
|
94
|
+
node.node_type = NodeType(body.node_type)
|
|
95
|
+
if body.action is not None:
|
|
96
|
+
node.action = body.action
|
|
97
|
+
if body.action_type is not None:
|
|
98
|
+
node.action_type = body.action_type
|
|
99
|
+
if body.metadata is not None:
|
|
100
|
+
node.metadata = body.metadata
|
|
101
|
+
|
|
102
|
+
tree = await service.get_tree(tree_id)
|
|
103
|
+
if tree:
|
|
104
|
+
tree.nodes[node_id] = node
|
|
105
|
+
await service.save_tree(tree)
|
|
106
|
+
|
|
107
|
+
return APIResponse(success=True, data=node.model_dump())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.delete("/node/{node_id}", response_model=APIResponse)
|
|
111
|
+
async def delete_node(
|
|
112
|
+
node_id: str,
|
|
113
|
+
tree_id: str = Query(...),
|
|
114
|
+
):
|
|
115
|
+
service = get_service()
|
|
116
|
+
|
|
117
|
+
node = await service.remove_node(tree_id, node_id)
|
|
118
|
+
if node is None:
|
|
119
|
+
return APIResponse(success=False, error="Node not found or failed to delete")
|
|
120
|
+
|
|
121
|
+
return APIResponse(success=True, data={"id": node_id})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional, Any
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Query, HTTPException
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from hits_core.storage.file_store import FileStorage
|
|
9
|
+
from hits_core.models.work_log import WorkLog, WorkLogSource, WorkLogResultType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
_storage: Optional[FileStorage] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_storage() -> FileStorage:
|
|
18
|
+
global _storage
|
|
19
|
+
if _storage is None:
|
|
20
|
+
_storage = FileStorage()
|
|
21
|
+
return _storage
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkLogCreate(BaseModel):
|
|
25
|
+
source: str
|
|
26
|
+
performed_by: str
|
|
27
|
+
request_text: Optional[str] = None
|
|
28
|
+
request_by: Optional[str] = None
|
|
29
|
+
result_type: Optional[str] = "none"
|
|
30
|
+
result_ref: Optional[str] = None
|
|
31
|
+
result_data: Optional[dict] = None
|
|
32
|
+
context: Optional[str] = None
|
|
33
|
+
tags: Optional[list[str]] = None
|
|
34
|
+
project_path: Optional[str] = None
|
|
35
|
+
node_id: Optional[str] = None
|
|
36
|
+
category: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WorkLogUpdate(BaseModel):
|
|
40
|
+
context: Optional[str] = None
|
|
41
|
+
tags: Optional[list[str]] = None
|
|
42
|
+
category: Optional[str] = None
|
|
43
|
+
node_id: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class APIResponse(BaseModel):
|
|
47
|
+
success: bool
|
|
48
|
+
data: Optional[Any] = None
|
|
49
|
+
error: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/work-log", response_model=APIResponse)
|
|
53
|
+
async def create_work_log(body: WorkLogCreate):
|
|
54
|
+
storage = get_storage()
|
|
55
|
+
|
|
56
|
+
log = WorkLog(
|
|
57
|
+
id=str(uuid4())[:8],
|
|
58
|
+
source=WorkLogSource(body.source),
|
|
59
|
+
performed_by=body.performed_by,
|
|
60
|
+
request_text=body.request_text,
|
|
61
|
+
request_by=body.request_by,
|
|
62
|
+
result_type=WorkLogResultType(body.result_type or "none"),
|
|
63
|
+
result_ref=body.result_ref,
|
|
64
|
+
result_data=body.result_data,
|
|
65
|
+
context=body.context,
|
|
66
|
+
tags=body.tags or [],
|
|
67
|
+
project_path=body.project_path,
|
|
68
|
+
node_id=body.node_id,
|
|
69
|
+
category=body.category,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
success = await storage.save_work_log(log)
|
|
73
|
+
if not success:
|
|
74
|
+
return APIResponse(success=False, error="Failed to save work log")
|
|
75
|
+
|
|
76
|
+
return APIResponse(success=True, data=log.model_dump())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/work-log/{log_id}", response_model=APIResponse)
|
|
80
|
+
async def get_work_log(log_id: str):
|
|
81
|
+
storage = get_storage()
|
|
82
|
+
log = await storage.load_work_log(log_id)
|
|
83
|
+
|
|
84
|
+
if log is None:
|
|
85
|
+
return APIResponse(success=False, error="Work log not found")
|
|
86
|
+
|
|
87
|
+
return APIResponse(success=True, data=log.model_dump())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.put("/work-log/{log_id}", response_model=APIResponse)
|
|
91
|
+
async def update_work_log(log_id: str, body: WorkLogUpdate):
|
|
92
|
+
storage = get_storage()
|
|
93
|
+
log = await storage.load_work_log(log_id)
|
|
94
|
+
|
|
95
|
+
if log is None:
|
|
96
|
+
return APIResponse(success=False, error="Work log not found")
|
|
97
|
+
|
|
98
|
+
if body.context is not None:
|
|
99
|
+
log.context = body.context
|
|
100
|
+
if body.tags is not None:
|
|
101
|
+
log.tags = body.tags
|
|
102
|
+
if body.category is not None:
|
|
103
|
+
log.category = body.category
|
|
104
|
+
if body.node_id is not None:
|
|
105
|
+
log.node_id = body.node_id
|
|
106
|
+
|
|
107
|
+
success = await storage.save_work_log(log)
|
|
108
|
+
if not success:
|
|
109
|
+
return APIResponse(success=False, error="Failed to update work log")
|
|
110
|
+
|
|
111
|
+
return APIResponse(success=True, data=log.model_dump())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.delete("/work-log/{log_id}", response_model=APIResponse)
|
|
115
|
+
async def delete_work_log(log_id: str):
|
|
116
|
+
storage = get_storage()
|
|
117
|
+
success = await storage.delete_work_log(log_id)
|
|
118
|
+
|
|
119
|
+
if not success:
|
|
120
|
+
return APIResponse(success=False, error="Failed to delete work log")
|
|
121
|
+
|
|
122
|
+
return APIResponse(success=True, data={"id": log_id})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.get("/work-logs", response_model=APIResponse)
|
|
126
|
+
async def list_work_logs(
|
|
127
|
+
source: Optional[str] = Query(None),
|
|
128
|
+
performed_by: Optional[str] = Query(None),
|
|
129
|
+
since: Optional[str] = Query(None),
|
|
130
|
+
project_path: Optional[str] = Query(None, description="Filter by project path"),
|
|
131
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
132
|
+
):
|
|
133
|
+
storage = get_storage()
|
|
134
|
+
|
|
135
|
+
since_dt = None
|
|
136
|
+
if since:
|
|
137
|
+
try:
|
|
138
|
+
since_dt = datetime.fromisoformat(since)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return APIResponse(success=False, error="Invalid since format. Use ISO format.")
|
|
141
|
+
|
|
142
|
+
logs = await storage.list_work_logs(
|
|
143
|
+
source=source,
|
|
144
|
+
performed_by=performed_by,
|
|
145
|
+
since=since_dt,
|
|
146
|
+
project_path=project_path,
|
|
147
|
+
limit=limit,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return APIResponse(
|
|
151
|
+
success=True,
|
|
152
|
+
data=[log.model_dump() for log in logs]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.get("/work-logs/search", response_model=APIResponse)
|
|
157
|
+
async def search_work_logs(
|
|
158
|
+
q: str = Query(..., description="Search query"),
|
|
159
|
+
project_path: Optional[str] = Query(None, description="Filter by project path"),
|
|
160
|
+
limit: int = Query(50, ge=1, le=200),
|
|
161
|
+
):
|
|
162
|
+
"""Search work logs by keyword, optionally scoped to a project."""
|
|
163
|
+
storage = get_storage()
|
|
164
|
+
|
|
165
|
+
logs = await storage.search_work_logs(
|
|
166
|
+
query=q,
|
|
167
|
+
project_path=project_path,
|
|
168
|
+
limit=limit,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return APIResponse(
|
|
172
|
+
success=True,
|
|
173
|
+
data=[log.model_dump() for log in logs]
|
|
174
|
+
)
|