@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,143 @@
|
|
|
1
|
+
# HITS 프로젝트 지식 트리 샘플
|
|
2
|
+
# 이 파일은 HITS 지식 트리 구조의 예시입니다.
|
|
3
|
+
|
|
4
|
+
tree:
|
|
5
|
+
id: hits-knowledge-001
|
|
6
|
+
name: "HITS 프로젝트 핵심 지식"
|
|
7
|
+
description: "HITS 시스템 개발에 필요한 핵심 지식 트리"
|
|
8
|
+
|
|
9
|
+
nodes:
|
|
10
|
+
# WHY Layer - 의도/목적
|
|
11
|
+
- id: why-architecture
|
|
12
|
+
layer: why
|
|
13
|
+
title: "시스템 아키텍처 이해"
|
|
14
|
+
description: |
|
|
15
|
+
HITS는 hits_core(순수 Python) + hits_ui(PySide6) 구조
|
|
16
|
+
→ 목적: AI 토큰 최적화 + 지식 보존
|
|
17
|
+
→ 격리: core는 GUI 의존성 없음
|
|
18
|
+
→ 저장: ~/.hits/data/에 중앙 집중
|
|
19
|
+
tokens_saved: 50
|
|
20
|
+
|
|
21
|
+
- id: why-handover
|
|
22
|
+
layer: why
|
|
23
|
+
title: "AI 세션 인수인계"
|
|
24
|
+
description: |
|
|
25
|
+
토큰 한계 등으로 AI 교체 시 프로젝트 컨텍스트 유지
|
|
26
|
+
→ 프로젝트별 project_path로 격리
|
|
27
|
+
→ 세션 종료 시 작업 기록, 시작 시 요약 조회
|
|
28
|
+
tokens_saved: 40
|
|
29
|
+
|
|
30
|
+
# HOW Layer - 논리/방법
|
|
31
|
+
- id: how-storage
|
|
32
|
+
layer: how
|
|
33
|
+
title: "중앙 집중 저장소"
|
|
34
|
+
parent_id: why-architecture
|
|
35
|
+
description: |
|
|
36
|
+
FileStorage 기본 경로: ~/.hits/data/
|
|
37
|
+
→ work_logs/: 작업 기록 JSON
|
|
38
|
+
→ trees/: 지식 트리
|
|
39
|
+
→ HITS_DATA_PATH 환경변수로 오버라이드
|
|
40
|
+
tokens_saved: 35
|
|
41
|
+
|
|
42
|
+
- id: how-mcp
|
|
43
|
+
layer: how
|
|
44
|
+
title: "MCP 인터페이스"
|
|
45
|
+
parent_id: why-handover
|
|
46
|
+
description: |
|
|
47
|
+
hits_core/mcp/server.py가 MCP 서버 제공
|
|
48
|
+
→ 5개 툴: record_work, get_handover, search_works, list_projects, get_recent
|
|
49
|
+
→ stdio transport로 Claude/OpenCode와 직접 통신
|
|
50
|
+
tokens_saved: 30
|
|
51
|
+
|
|
52
|
+
- id: how-api
|
|
53
|
+
layer: how
|
|
54
|
+
title: "HTTP API 서버"
|
|
55
|
+
parent_id: why-handover
|
|
56
|
+
description: |
|
|
57
|
+
FastAPI 기반, 포트 8765
|
|
58
|
+
→ /api/handover: 인수인계 요약
|
|
59
|
+
→ /api/work-log: 작업 기록 CRUD
|
|
60
|
+
→ /api/work-logs/search: 검색 (project_path 스코프)
|
|
61
|
+
tokens_saved: 30
|
|
62
|
+
|
|
63
|
+
- id: how-compression
|
|
64
|
+
layer: how
|
|
65
|
+
title: "시멘틱 압축"
|
|
66
|
+
parent_id: why-architecture
|
|
67
|
+
description: |
|
|
68
|
+
SemanticCompressor가 한국어 키워드를 기호로 치환
|
|
69
|
+
→ "따라서" → "→", "중요" → "★", "필수" → "!"
|
|
70
|
+
→ SLMFilter가 CRITICAL/IMPORTANT/NOISE 분류
|
|
71
|
+
node_type: "negative_path"
|
|
72
|
+
tokens_saved: 25
|
|
73
|
+
|
|
74
|
+
# WHAT Layer - 실행/작업
|
|
75
|
+
- id: what-run
|
|
76
|
+
layer: what
|
|
77
|
+
title: "실행 방법"
|
|
78
|
+
parent_id: how-storage
|
|
79
|
+
description: |
|
|
80
|
+
./run.sh # GUI 실행
|
|
81
|
+
./run.sh --check # pre-flight 체크
|
|
82
|
+
./run.sh --test # 테스트 실행
|
|
83
|
+
action: "bash ./run.sh"
|
|
84
|
+
action_type: shell
|
|
85
|
+
tokens_saved: 15
|
|
86
|
+
|
|
87
|
+
- id: what-record
|
|
88
|
+
layer: what
|
|
89
|
+
title: "작업 기록"
|
|
90
|
+
parent_id: how-api
|
|
91
|
+
description: |
|
|
92
|
+
curl -X POST http://localhost:8765/api/work-log \
|
|
93
|
+
-d '{"performed_by":"opencode","request_text":"요약","source":"ai_session","project_path":"/절대경로"}'
|
|
94
|
+
action: "curl -X POST http://localhost:8765/api/work-log"
|
|
95
|
+
action_type: shell
|
|
96
|
+
tokens_saved: 20
|
|
97
|
+
|
|
98
|
+
- id: what-handover
|
|
99
|
+
layer: what
|
|
100
|
+
title: "인수인계 조회"
|
|
101
|
+
parent_id: how-api
|
|
102
|
+
description: |
|
|
103
|
+
curl http://localhost:8765/api/handover?project_path=/my/project
|
|
104
|
+
→ 프로젝트별 작업 이력, 결정 사항, 미완료 항목 반환
|
|
105
|
+
action: "curl http://localhost:8765/api/handover?project_path=$(pwd)"
|
|
106
|
+
action_type: shell
|
|
107
|
+
tokens_saved: 20
|
|
108
|
+
|
|
109
|
+
# 인과관계 워크플로우
|
|
110
|
+
workflow:
|
|
111
|
+
id: ai-session-handover
|
|
112
|
+
name: "AI 세션 인수인계 워크플로우"
|
|
113
|
+
description: "AI 세션 전환 시 표준 인수인계 절차"
|
|
114
|
+
|
|
115
|
+
steps:
|
|
116
|
+
- id: step-start
|
|
117
|
+
name: "세션 시작"
|
|
118
|
+
step_type: trigger
|
|
119
|
+
node_id: why-handover
|
|
120
|
+
next_steps: ["step-check-handover"]
|
|
121
|
+
|
|
122
|
+
- id: step-check-handover
|
|
123
|
+
name: "이전 작업 확인"
|
|
124
|
+
step_type: action
|
|
125
|
+
node_id: what-handover
|
|
126
|
+
next_steps: ["step-work"]
|
|
127
|
+
|
|
128
|
+
- id: step-work
|
|
129
|
+
name: "작업 수행"
|
|
130
|
+
step_type: action
|
|
131
|
+
node_id: what-run
|
|
132
|
+
next_steps: ["step-record"]
|
|
133
|
+
|
|
134
|
+
- id: step-record
|
|
135
|
+
name: "작업 기록"
|
|
136
|
+
step_type: action
|
|
137
|
+
node_id: what-record
|
|
138
|
+
next_steps: ["step-end"]
|
|
139
|
+
|
|
140
|
+
- id: step-end
|
|
141
|
+
name: "세션 종료"
|
|
142
|
+
step_type: outcome
|
|
143
|
+
node_id: why-handover
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""AI module for token optimization and knowledge compression."""
|
|
2
|
+
|
|
3
|
+
from .compressor import SemanticCompressor
|
|
4
|
+
from .slm_filter import SLMFilter
|
|
5
|
+
from .llm_client import LLMClient
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SemanticCompressor",
|
|
9
|
+
"SLMFilter",
|
|
10
|
+
"LLMClient",
|
|
11
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Semantic compression for token-efficient knowledge storage."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from ..models.node import Node
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SemanticCompressor:
|
|
8
|
+
KEYWORD_MAP = {
|
|
9
|
+
"따라서": "→",
|
|
10
|
+
"그래서": "→",
|
|
11
|
+
"그러므로": "→",
|
|
12
|
+
"그러나": "↔",
|
|
13
|
+
"하지만": "↔",
|
|
14
|
+
"또한": "+",
|
|
15
|
+
"그리고": "+",
|
|
16
|
+
"또는": "|",
|
|
17
|
+
"만약": "?",
|
|
18
|
+
"만일": "?",
|
|
19
|
+
"그러면": "→",
|
|
20
|
+
"결과": "⊕",
|
|
21
|
+
"원인": "⊙",
|
|
22
|
+
"목적": "◎",
|
|
23
|
+
"방법": "⚙",
|
|
24
|
+
"주의": "⚠",
|
|
25
|
+
"중요": "★",
|
|
26
|
+
"필수": "!",
|
|
27
|
+
"선택": "○",
|
|
28
|
+
"성공": "✓",
|
|
29
|
+
"실패": "✗",
|
|
30
|
+
"버그": "🐛",
|
|
31
|
+
"수정": "🔧",
|
|
32
|
+
"배포": "🚀",
|
|
33
|
+
"테스트": "🧪",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
COMPRESSION_RULES = [
|
|
37
|
+
("입니다", ""),
|
|
38
|
+
("있습니다", "+"),
|
|
39
|
+
("없습니다", "-"),
|
|
40
|
+
("합니다", "."),
|
|
41
|
+
("되어야 합니다", "→!"),
|
|
42
|
+
("해야 합니다", "→!"),
|
|
43
|
+
("필요합니다", "!"),
|
|
44
|
+
("가능합니다", "✓"),
|
|
45
|
+
("불가능합니다", "✗"),
|
|
46
|
+
("중요합니다", "★!"),
|
|
47
|
+
("필수입니다", "!"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
def compress(self, text: str) -> str:
|
|
51
|
+
if not text:
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
result = text
|
|
55
|
+
|
|
56
|
+
for keyword, symbol in self.KEYWORD_MAP.items():
|
|
57
|
+
result = result.replace(keyword, f" {symbol} ")
|
|
58
|
+
|
|
59
|
+
for full, compressed in self.COMPRESSION_RULES:
|
|
60
|
+
result = result.replace(full, compressed)
|
|
61
|
+
|
|
62
|
+
while " " in result:
|
|
63
|
+
result = result.replace(" ", " ")
|
|
64
|
+
|
|
65
|
+
return result.strip()
|
|
66
|
+
|
|
67
|
+
def compress_node(self, node: Node) -> int:
|
|
68
|
+
original_length = len(node.description or "")
|
|
69
|
+
|
|
70
|
+
if node.description:
|
|
71
|
+
node.description = self.compress(node.description)
|
|
72
|
+
|
|
73
|
+
for key, value in node.metadata.items():
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
node.metadata[key] = self.compress(value)
|
|
76
|
+
|
|
77
|
+
compressed_length = len(node.description or "")
|
|
78
|
+
tokens_saved = max(0, (original_length - compressed_length) // 4)
|
|
79
|
+
|
|
80
|
+
node.tokens_saved = tokens_saved
|
|
81
|
+
return tokens_saved
|
|
82
|
+
|
|
83
|
+
def estimate_tokens(self, text: str) -> int:
|
|
84
|
+
korean_chars = sum(1 for c in text if '\uAC00' <= c <= '\uD7A3')
|
|
85
|
+
other_chars = len(text) - korean_chars
|
|
86
|
+
return (korean_chars // 2) + (other_chars // 4)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""LLM client for on-demand knowledge analysis."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LLMProvider(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
async def generate(self, prompt: str, max_tokens: int = 1000) -> str:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MockLLMProvider(LLMProvider):
|
|
14
|
+
async def generate(self, prompt: str, max_tokens: int = 1000) -> str:
|
|
15
|
+
return f"[Mock Response] Analyzed: {prompt[:50]}..."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LLMClient:
|
|
19
|
+
def __init__(self, provider: Optional[LLMProvider] = None):
|
|
20
|
+
self.provider = provider or MockLLMProvider()
|
|
21
|
+
self.total_tokens_used = 0
|
|
22
|
+
|
|
23
|
+
async def analyze_node(self, node_data: str, max_tokens: int = 500) -> str:
|
|
24
|
+
prompt = f"""다음 지식 노드를 분석하여 핵심 맥락을 요약하세요:
|
|
25
|
+
|
|
26
|
+
{node_data}
|
|
27
|
+
|
|
28
|
+
요약:"""
|
|
29
|
+
|
|
30
|
+
response = await self.provider.generate(prompt, max_tokens)
|
|
31
|
+
self.total_tokens_used += max_tokens
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
async def suggest_children(self, node_data: str, max_tokens: int = 300) -> list[str]:
|
|
35
|
+
prompt = f"""다음 지식 노드에 대해 적절한 하위 항목을 3-5개 제안하세요:
|
|
36
|
+
|
|
37
|
+
{node_data}
|
|
38
|
+
|
|
39
|
+
하위 항목:"""
|
|
40
|
+
|
|
41
|
+
response = await self.provider.generate(prompt, max_tokens)
|
|
42
|
+
self.total_tokens_used += max_tokens
|
|
43
|
+
|
|
44
|
+
suggestions = [
|
|
45
|
+
line.strip().lstrip("0123456789.-) ")
|
|
46
|
+
for line in response.split("\n")
|
|
47
|
+
if line.strip()
|
|
48
|
+
]
|
|
49
|
+
return suggestions[:5]
|
|
50
|
+
|
|
51
|
+
async def generate_handover_summary(
|
|
52
|
+
self,
|
|
53
|
+
tree_data: str,
|
|
54
|
+
max_tokens: int = 1000
|
|
55
|
+
) -> str:
|
|
56
|
+
prompt = f"""다음 지식 트리를 기반으로 인수인계 요약을 작성하세요.
|
|
57
|
+
주요 의사결정, 실패 경험, 필수 지식을 포함하세요:
|
|
58
|
+
|
|
59
|
+
{tree_data}
|
|
60
|
+
|
|
61
|
+
인수인계 요약:"""
|
|
62
|
+
|
|
63
|
+
response = await self.provider.generate(prompt, max_tokens)
|
|
64
|
+
self.total_tokens_used += max_tokens
|
|
65
|
+
return response
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""SLM-based pre-filter for token cost optimization."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContentImportance(str, Enum):
|
|
9
|
+
CRITICAL = "critical"
|
|
10
|
+
IMPORTANT = "important"
|
|
11
|
+
NOISE = "noise"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class FilterResult:
|
|
16
|
+
content: str
|
|
17
|
+
importance: ContentImportance
|
|
18
|
+
confidence: float
|
|
19
|
+
keywords: list[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CRITICAL_KEYWORDS = [
|
|
23
|
+
"장애", "에러", "버그", "핫픽스", "긴급", "중단", "실패",
|
|
24
|
+
"롤백", "복구", "보안", "취약점", "인증", "권한",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
IMPORTANT_KEYWORDS = [
|
|
28
|
+
"배포", "빌드", "테스트", "설정", "구성", "변경",
|
|
29
|
+
"업데이트", "수정", "개선", "최적화", "성능",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
NOISE_PATTERNS = [
|
|
33
|
+
"로그:", "debug:", "trace:", "info:",
|
|
34
|
+
"TODO:", "FIXME:", "XXX:",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SLMFilter:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
critical_threshold: float = 0.7,
|
|
42
|
+
important_threshold: float = 0.5,
|
|
43
|
+
):
|
|
44
|
+
self.critical_threshold = critical_threshold
|
|
45
|
+
self.important_threshold = important_threshold
|
|
46
|
+
|
|
47
|
+
def classify(self, content: str) -> FilterResult:
|
|
48
|
+
if not content:
|
|
49
|
+
return FilterResult(
|
|
50
|
+
content=content,
|
|
51
|
+
importance=ContentImportance.NOISE,
|
|
52
|
+
confidence=1.0,
|
|
53
|
+
keywords=[],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
keywords = []
|
|
57
|
+
critical_score = 0.0
|
|
58
|
+
important_score = 0.0
|
|
59
|
+
noise_score = 0.0
|
|
60
|
+
|
|
61
|
+
content_lower = content.lower()
|
|
62
|
+
|
|
63
|
+
for keyword in CRITICAL_KEYWORDS:
|
|
64
|
+
if keyword in content:
|
|
65
|
+
critical_score += 1.0
|
|
66
|
+
keywords.append(keyword)
|
|
67
|
+
|
|
68
|
+
for keyword in IMPORTANT_KEYWORDS:
|
|
69
|
+
if keyword in content:
|
|
70
|
+
important_score += 0.5
|
|
71
|
+
keywords.append(keyword)
|
|
72
|
+
|
|
73
|
+
for pattern in NOISE_PATTERNS:
|
|
74
|
+
if pattern.lower() in content_lower:
|
|
75
|
+
noise_score += 0.5
|
|
76
|
+
|
|
77
|
+
total_chars = max(len(content), 1)
|
|
78
|
+
|
|
79
|
+
# Score normalization: each keyword contributes meaningfully
|
|
80
|
+
# Critical: even 1 keyword should be enough for critical content
|
|
81
|
+
critical_score = min(critical_score / 2.0, 1.0)
|
|
82
|
+
important_score = min(important_score / 3.0, 1.0)
|
|
83
|
+
noise_score = min(noise_score / 1.0, 1.0)
|
|
84
|
+
|
|
85
|
+
# Decision: critical takes priority over noise
|
|
86
|
+
# "긴급 장애 발생" has both critical and noise-like exclamation
|
|
87
|
+
# but the critical keywords should dominate
|
|
88
|
+
if critical_score > 0:
|
|
89
|
+
importance = ContentImportance.CRITICAL
|
|
90
|
+
confidence = critical_score
|
|
91
|
+
elif noise_score > important_score and noise_score >= 0.5:
|
|
92
|
+
importance = ContentImportance.NOISE
|
|
93
|
+
confidence = noise_score
|
|
94
|
+
elif important_score > 0:
|
|
95
|
+
importance = ContentImportance.IMPORTANT
|
|
96
|
+
confidence = important_score
|
|
97
|
+
else:
|
|
98
|
+
importance = ContentImportance.IMPORTANT
|
|
99
|
+
confidence = 0.5
|
|
100
|
+
|
|
101
|
+
return FilterResult(
|
|
102
|
+
content=content,
|
|
103
|
+
importance=importance,
|
|
104
|
+
confidence=confidence,
|
|
105
|
+
keywords=keywords,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def filter_batch(self, contents: list[str]) -> tuple[list[str], list[str]]:
|
|
109
|
+
critical_important = []
|
|
110
|
+
noise = []
|
|
111
|
+
|
|
112
|
+
for content in contents:
|
|
113
|
+
result = self.classify(content)
|
|
114
|
+
if result.importance in (ContentImportance.CRITICAL, ContentImportance.IMPORTANT):
|
|
115
|
+
critical_important.append(content)
|
|
116
|
+
else:
|
|
117
|
+
noise.append(content)
|
|
118
|
+
|
|
119
|
+
return critical_important, noise
|
|
120
|
+
|
|
121
|
+
def estimate_filter_ratio(self, contents: list[str]) -> float:
|
|
122
|
+
if not contents:
|
|
123
|
+
return 0.0
|
|
124
|
+
|
|
125
|
+
critical_important, _ = self.filter_batch(contents)
|
|
126
|
+
return len(critical_important) / len(contents)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Authentication API routes.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
- POST /api/auth/register → Create user (first user becomes admin)
|
|
5
|
+
- POST /api/auth/login → Authenticate, set HttpOnly cookies
|
|
6
|
+
- POST /api/auth/logout → Clear auth cookies
|
|
7
|
+
- POST /api/auth/refresh → Refresh access token
|
|
8
|
+
- GET /api/auth/me → Get current user info
|
|
9
|
+
- PUT /api/auth/password → Change password
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Request, Response, HTTPException, Depends, status
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from hits_core.auth.manager import get_auth_manager
|
|
17
|
+
from hits_core.auth.dependencies import get_current_user, require_auth
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- Request/Response Models ---
|
|
24
|
+
|
|
25
|
+
class RegisterRequest(BaseModel):
|
|
26
|
+
username: str = Field(..., min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$")
|
|
27
|
+
password: str = Field(..., min_length=8, max_length=128)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LoginRequest(BaseModel):
|
|
31
|
+
username: str = Field(..., min_length=1)
|
|
32
|
+
password: str = Field(..., min_length=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ChangePasswordRequest(BaseModel):
|
|
36
|
+
old_password: str = Field(..., min_length=1)
|
|
37
|
+
new_password: str = Field(..., min_length=8, max_length=128)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class APIResponse(BaseModel):
|
|
41
|
+
success: bool
|
|
42
|
+
data: Optional[Any] = None
|
|
43
|
+
error: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- Cookie Settings ---
|
|
47
|
+
# Secure cookies: HttpOnly (no JS access), Secure (HTTPS only in prod),
|
|
48
|
+
# SameSite=Lax (CSRF protection), path restricted to /api
|
|
49
|
+
def _cookie_params(secure: bool = False) -> dict:
|
|
50
|
+
return {
|
|
51
|
+
"httponly": True,
|
|
52
|
+
"secure": secure,
|
|
53
|
+
"samesite": "lax",
|
|
54
|
+
"path": "/",
|
|
55
|
+
"max_age": None, # session cookie for access
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Endpoints ---
|
|
60
|
+
|
|
61
|
+
@router.post("/auth/register", response_model=APIResponse)
|
|
62
|
+
async def register(body: RegisterRequest, request: Request):
|
|
63
|
+
"""Register a new user. First user automatically becomes admin."""
|
|
64
|
+
auth = get_auth_manager()
|
|
65
|
+
|
|
66
|
+
if auth.has_any_user():
|
|
67
|
+
# Registration requires auth after first user
|
|
68
|
+
user = await get_current_user(request)
|
|
69
|
+
if not user or user.get("role") != "admin":
|
|
70
|
+
raise HTTPException(
|
|
71
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
72
|
+
detail="Only admins can create new users",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if auth.user_exists(body.username):
|
|
76
|
+
return APIResponse(success=False, error="Username already exists")
|
|
77
|
+
|
|
78
|
+
success = auth.create_user(body.username, body.password)
|
|
79
|
+
if not success:
|
|
80
|
+
return APIResponse(success=False, error="Failed to create user")
|
|
81
|
+
|
|
82
|
+
return APIResponse(
|
|
83
|
+
success=True,
|
|
84
|
+
data={
|
|
85
|
+
"username": body.username,
|
|
86
|
+
"role": "admin" if not auth.has_any_user() else "user",
|
|
87
|
+
"message": "User created successfully",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.post("/auth/login", response_model=APIResponse)
|
|
93
|
+
async def login(body: LoginRequest, response: Response):
|
|
94
|
+
"""Authenticate and set HttpOnly JWT cookies."""
|
|
95
|
+
auth = get_auth_manager()
|
|
96
|
+
tokens = auth.create_tokens(body.username, body.password)
|
|
97
|
+
|
|
98
|
+
if not tokens:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
101
|
+
detail="Invalid username or password",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Set HttpOnly cookies
|
|
105
|
+
# Access token: short-lived (15 min)
|
|
106
|
+
response.set_cookie(
|
|
107
|
+
key="access_token",
|
|
108
|
+
value=tokens["access_token"],
|
|
109
|
+
httponly=True,
|
|
110
|
+
secure=False, # Set True in production with HTTPS
|
|
111
|
+
samesite="lax",
|
|
112
|
+
path="/",
|
|
113
|
+
max_age=900, # 15 minutes
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Refresh token: long-lived (7 days)
|
|
117
|
+
response.set_cookie(
|
|
118
|
+
key="refresh_token",
|
|
119
|
+
value=tokens["refresh_token"],
|
|
120
|
+
httponly=True,
|
|
121
|
+
secure=False, # Set True in production with HTTPS
|
|
122
|
+
samesite="lax",
|
|
123
|
+
path="/api/auth/refresh", # Only sent to refresh endpoint
|
|
124
|
+
max_age=604800, # 7 days
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return APIResponse(
|
|
128
|
+
success=True,
|
|
129
|
+
data={
|
|
130
|
+
"username": tokens["username"],
|
|
131
|
+
"role": tokens["role"],
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.post("/auth/logout", response_model=APIResponse)
|
|
137
|
+
async def logout(response: Response):
|
|
138
|
+
"""Clear authentication cookies."""
|
|
139
|
+
response.delete_cookie(key="access_token", path="/")
|
|
140
|
+
response.delete_cookie(key="refresh_token", path="/api/auth/refresh")
|
|
141
|
+
return APIResponse(success=True, data={"message": "Logged out"})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.post("/auth/refresh", response_model=APIResponse)
|
|
145
|
+
async def refresh_token(request: Request, response: Response):
|
|
146
|
+
"""Refresh access token using refresh token cookie."""
|
|
147
|
+
refresh_token = request.cookies.get("refresh_token")
|
|
148
|
+
if not refresh_token:
|
|
149
|
+
raise HTTPException(
|
|
150
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
151
|
+
detail="No refresh token",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
auth = get_auth_manager()
|
|
155
|
+
new_access = auth.refresh_access_token(refresh_token)
|
|
156
|
+
|
|
157
|
+
if not new_access:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
160
|
+
detail="Invalid or expired refresh token",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
response.set_cookie(
|
|
164
|
+
key="access_token",
|
|
165
|
+
value=new_access,
|
|
166
|
+
httponly=True,
|
|
167
|
+
secure=False,
|
|
168
|
+
samesite="lax",
|
|
169
|
+
path="/",
|
|
170
|
+
max_age=900,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return APIResponse(success=True, data={"message": "Token refreshed"})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.get("/auth/me", response_model=APIResponse)
|
|
177
|
+
async def get_me(user: dict = Depends(require_auth)):
|
|
178
|
+
"""Get current authenticated user info."""
|
|
179
|
+
return APIResponse(success=True, data=user)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@router.put("/auth/password", response_model=APIResponse)
|
|
183
|
+
async def change_password(
|
|
184
|
+
body: ChangePasswordRequest,
|
|
185
|
+
user: dict = Depends(require_auth),
|
|
186
|
+
):
|
|
187
|
+
"""Change password for the authenticated user."""
|
|
188
|
+
auth = get_auth_manager()
|
|
189
|
+
success = auth.change_password(user["username"], body.old_password, body.new_password)
|
|
190
|
+
|
|
191
|
+
if not success:
|
|
192
|
+
return APIResponse(success=False, error="Invalid current password")
|
|
193
|
+
|
|
194
|
+
return APIResponse(success=True, data={"message": "Password changed"})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.get("/auth/status", response_model=APIResponse)
|
|
198
|
+
async def auth_status(request: Request):
|
|
199
|
+
"""Check if authentication is initialized (has any users)."""
|
|
200
|
+
auth = get_auth_manager()
|
|
201
|
+
user = await get_current_user(request)
|
|
202
|
+
|
|
203
|
+
return APIResponse(
|
|
204
|
+
success=True,
|
|
205
|
+
data={
|
|
206
|
+
"initialized": auth.has_any_user(),
|
|
207
|
+
"authenticated": user is not None,
|
|
208
|
+
"username": user.get("username") if user else None,
|
|
209
|
+
"role": user.get("role") if user else None,
|
|
210
|
+
},
|
|
211
|
+
)
|