supernote 0.13.6__py3-none-any.whl
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.
- supernote/__init__.py +28 -0
- supernote/alembic/__init__.py +0 -0
- supernote/alembic/env.py +121 -0
- supernote/alembic/script.py.mako +28 -0
- supernote/alembic/versions/0543a383957b_initial_schema.py +312 -0
- supernote/alembic.ini +149 -0
- supernote/cli/__init__.py +1 -0
- supernote/cli/admin.py +178 -0
- supernote/cli/client.py +434 -0
- supernote/cli/main.py +61 -0
- supernote/cli/notebook.py +313 -0
- supernote/cli/server.py +89 -0
- supernote/client/__init__.py +28 -0
- supernote/client/admin.py +76 -0
- supernote/client/api.py +135 -0
- supernote/client/api_model.py +231 -0
- supernote/client/auth.py +84 -0
- supernote/client/client.py +365 -0
- supernote/client/device.py +361 -0
- supernote/client/exceptions.py +33 -0
- supernote/client/extended.py +20 -0
- supernote/client/hashing.py +50 -0
- supernote/client/login_client.py +154 -0
- supernote/client/schedule.py +149 -0
- supernote/client/summary.py +156 -0
- supernote/client/web.py +213 -0
- supernote/models/__init__.py +14 -0
- supernote/models/auth.py +380 -0
- supernote/models/base.py +266 -0
- supernote/models/equipment.py +276 -0
- supernote/models/extended.py +124 -0
- supernote/models/file_common.py +95 -0
- supernote/models/file_device.py +571 -0
- supernote/models/file_web.py +602 -0
- supernote/models/schedule.py +663 -0
- supernote/models/summary.py +830 -0
- supernote/models/system.py +431 -0
- supernote/models/user.py +638 -0
- supernote/notebook/__init__.py +67 -0
- supernote/notebook/color.py +93 -0
- supernote/notebook/converter.py +560 -0
- supernote/notebook/decoder.py +398 -0
- supernote/notebook/exceptions.py +43 -0
- supernote/notebook/fileformat.py +463 -0
- supernote/notebook/manipulator.py +420 -0
- supernote/notebook/parser.py +737 -0
- supernote/notebook/utils.py +49 -0
- supernote/py.typed +0 -0
- supernote/server/__init__.py +12 -0
- supernote/server/app.py +428 -0
- supernote/server/config.py +265 -0
- supernote/server/constants.py +26 -0
- supernote/server/db/__init__.py +9 -0
- supernote/server/db/base.py +8 -0
- supernote/server/db/migrations.py +44 -0
- supernote/server/db/models/__init__.py +23 -0
- supernote/server/db/models/device.py +17 -0
- supernote/server/db/models/file.py +107 -0
- supernote/server/db/models/kv.py +17 -0
- supernote/server/db/models/login_record.py +22 -0
- supernote/server/db/models/note_processing.py +100 -0
- supernote/server/db/models/schedule.py +83 -0
- supernote/server/db/models/summary.py +122 -0
- supernote/server/db/models/user.py +30 -0
- supernote/server/db/session.py +88 -0
- supernote/server/events.py +63 -0
- supernote/server/exceptions.py +153 -0
- supernote/server/mcp/__init__.py +1 -0
- supernote/server/mcp/models.py +109 -0
- supernote/server/mcp/server.py +176 -0
- supernote/server/resources/__init__.py +0 -0
- supernote/server/resources/prompts/__init__.py +0 -0
- supernote/server/resources/prompts/ocr/common/context.md +19 -0
- supernote/server/resources/prompts/ocr/common/legend.md +11 -0
- supernote/server/resources/prompts/ocr/daily/prompt.md +12 -0
- supernote/server/resources/prompts/ocr/default/system.md +14 -0
- supernote/server/resources/prompts/ocr/monthly/prompt.md +22 -0
- supernote/server/resources/prompts/ocr/weekly/prompt.md +16 -0
- supernote/server/resources/prompts/summary/common/instruction.md +8 -0
- supernote/server/resources/prompts/summary/daily/prompt.md +6 -0
- supernote/server/resources/prompts/summary/default/prompt.md +4 -0
- supernote/server/resources/prompts/summary/monthly/prompt.md +6 -0
- supernote/server/resources/prompts/summary/weekly/prompt.md +6 -0
- supernote/server/routes/admin.py +109 -0
- supernote/server/routes/auth.py +337 -0
- supernote/server/routes/decorators.py +13 -0
- supernote/server/routes/extended.py +139 -0
- supernote/server/routes/file_device.py +593 -0
- supernote/server/routes/file_web.py +597 -0
- supernote/server/routes/oss.py +317 -0
- supernote/server/routes/schedule.py +223 -0
- supernote/server/routes/summary.py +390 -0
- supernote/server/routes/system.py +57 -0
- supernote/server/services/__init__.py +15 -0
- supernote/server/services/blob.py +214 -0
- supernote/server/services/coordination.py +171 -0
- supernote/server/services/file.py +970 -0
- supernote/server/services/gemini.py +66 -0
- supernote/server/services/integrity.py +112 -0
- supernote/server/services/processor.py +331 -0
- supernote/server/services/processor_modules/__init__.py +145 -0
- supernote/server/services/processor_modules/gemini_embedding.py +112 -0
- supernote/server/services/processor_modules/gemini_ocr.py +149 -0
- supernote/server/services/processor_modules/page_hashing.py +220 -0
- supernote/server/services/processor_modules/png_conversion.py +107 -0
- supernote/server/services/processor_modules/summary.py +302 -0
- supernote/server/services/prompt_loader.py +133 -0
- supernote/server/services/schedule.py +161 -0
- supernote/server/services/search.py +250 -0
- supernote/server/services/summary.py +419 -0
- supernote/server/services/user.py +480 -0
- supernote/server/services/vfs.py +476 -0
- supernote/server/static/favicon.ico +0 -0
- supernote/server/static/index.html +186 -0
- supernote/server/static/js/api/client.js +463 -0
- supernote/server/static/js/components/FileCard.js +55 -0
- supernote/server/static/js/components/FileViewer.js +137 -0
- supernote/server/static/js/components/LoginCard.js +64 -0
- supernote/server/static/js/components/MoveModal.js +74 -0
- supernote/server/static/js/components/RenameModal.js +39 -0
- supernote/server/static/js/components/SummaryPanel.js +101 -0
- supernote/server/static/js/components/SystemPanel.js +158 -0
- supernote/server/static/js/composables/useFileSystem.js +116 -0
- supernote/server/static/js/main.js +241 -0
- supernote/server/static/style.css +14 -0
- supernote/server/utils/hashing.py +18 -0
- supernote/server/utils/note_content.py +76 -0
- supernote/server/utils/paths.py +58 -0
- supernote/server/utils/rate_limit.py +52 -0
- supernote/server/utils/tasks.py +71 -0
- supernote/server/utils/unique_id.py +48 -0
- supernote/server/utils/url_signer.py +218 -0
- supernote-0.13.6.dist-info/METADATA +183 -0
- supernote-0.13.6.dist-info/RECORD +138 -0
- supernote-0.13.6.dist-info/WHEEL +5 -0
- supernote-0.13.6.dist-info/entry_points.txt +3 -0
- supernote-0.13.6.dist-info/licenses/LICENSE +201 -0
- supernote-0.13.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from mashumaro.jsonschema import build_json_schema
|
|
8
|
+
from mashumaro.mixins.json import DataClassJSONMixin
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
|
|
11
|
+
from supernote.models.summary import (
|
|
12
|
+
METADATA_SEGMENTS,
|
|
13
|
+
AddSummaryDTO,
|
|
14
|
+
UpdateSummaryDTO,
|
|
15
|
+
)
|
|
16
|
+
from supernote.server.config import ServerConfig
|
|
17
|
+
from supernote.server.db.models.file import UserFileDO
|
|
18
|
+
from supernote.server.db.models.note_processing import NotePageContentDO
|
|
19
|
+
from supernote.server.db.models.user import UserDO
|
|
20
|
+
from supernote.server.db.session import DatabaseSessionManager
|
|
21
|
+
from supernote.server.services.file import FileService
|
|
22
|
+
from supernote.server.services.gemini import GeminiService
|
|
23
|
+
from supernote.server.services.processor_modules import ProcessorModule
|
|
24
|
+
from supernote.server.services.prompt_loader import PROMPT_LOADER, PromptId
|
|
25
|
+
from supernote.server.services.summary import SummaryService
|
|
26
|
+
from supernote.server.utils.note_content import format_page_metadata
|
|
27
|
+
from supernote.server.utils.paths import get_summary_id, get_transcript_id
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Define structured output schema
|
|
33
|
+
@dataclass
|
|
34
|
+
class SummarySegment(DataClassJSONMixin):
|
|
35
|
+
date_range: str = field(
|
|
36
|
+
metadata={
|
|
37
|
+
"description": "The date range covered by this segment (e.g., '2023-10-27', 'Week of Oct 27')."
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
summary: str = field(
|
|
41
|
+
metadata={
|
|
42
|
+
"description": "A concise summary of the events, tasks, and notes for this period."
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
extracted_dates: List[str] = field(
|
|
46
|
+
metadata={
|
|
47
|
+
"description": "List of specific dates derived from the content in ISO 8601 format (YYYY-MM-DD)."
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
page_refs: List[int] = field(
|
|
51
|
+
metadata={
|
|
52
|
+
"description": "List of 1-indexed page numbers typically found in the text as '--- Page X ---'."
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SummaryResponse(DataClassJSONMixin):
|
|
59
|
+
segments: List[SummarySegment] = field(
|
|
60
|
+
metadata={
|
|
61
|
+
"description": "List of summary segments extracted from the transcript."
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SummaryModule(ProcessorModule):
|
|
67
|
+
"""Module responsible for aggregating OCR text and generating summaries for a note."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
file_service: FileService,
|
|
72
|
+
config: ServerConfig,
|
|
73
|
+
gemini_service: GeminiService,
|
|
74
|
+
summary_service: SummaryService,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.file_service = file_service
|
|
77
|
+
self.config = config
|
|
78
|
+
self.gemini_service = gemini_service
|
|
79
|
+
self.summary_service = summary_service
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def name(self) -> str:
|
|
83
|
+
return "SummaryModule"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def task_type(self) -> str:
|
|
87
|
+
return "SUMMARY_GENERATION"
|
|
88
|
+
|
|
89
|
+
async def run_if_needed(
|
|
90
|
+
self,
|
|
91
|
+
file_id: int,
|
|
92
|
+
session_manager: DatabaseSessionManager,
|
|
93
|
+
page_index: Optional[int] = None,
|
|
94
|
+
page_id: Optional[str] = None,
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""Determines if summary generation is needed."""
|
|
97
|
+
# Summary is a file-level task (global), not page-level.
|
|
98
|
+
if page_index is not None:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
if not self.gemini_service.is_configured:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
if not await super().run_if_needed(file_id, session_manager, page_index):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
async def process(
|
|
110
|
+
self,
|
|
111
|
+
file_id: int,
|
|
112
|
+
session_manager: DatabaseSessionManager,
|
|
113
|
+
page_index: Optional[int] = None,
|
|
114
|
+
page_id: Optional[str] = None,
|
|
115
|
+
**kwargs: object,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Generates an AI summary for the given file.
|
|
119
|
+
|
|
120
|
+
1. Aggregates all OCR text for the file.
|
|
121
|
+
2. Sends to Gemini for summarization and date extraction.
|
|
122
|
+
3. Stores the result as a new Summary.
|
|
123
|
+
"""
|
|
124
|
+
logger.info(f"Starting summary generation for file_id={file_id}")
|
|
125
|
+
|
|
126
|
+
async with session_manager.session() as session:
|
|
127
|
+
# 1. Fetch File Info
|
|
128
|
+
file_do = await session.get(UserFileDO, file_id)
|
|
129
|
+
if not file_do:
|
|
130
|
+
logger.error(f"File {file_id} not found.")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
user: Optional[UserDO] = await session.get(UserDO, file_do.user_id)
|
|
134
|
+
if not user or not user.email:
|
|
135
|
+
logger.error(f"User for file {file_id} not found.")
|
|
136
|
+
return
|
|
137
|
+
user_email = user.email
|
|
138
|
+
|
|
139
|
+
# 2. Key Generation
|
|
140
|
+
file_basis = file_do.storage_key or str(file_do.id)
|
|
141
|
+
summary_uuid = get_summary_id(file_basis)
|
|
142
|
+
transcript_uuid = get_transcript_id(file_basis)
|
|
143
|
+
|
|
144
|
+
# 3. Aggregate OCR Text
|
|
145
|
+
# We want to format the text with page markers so the model can cite them.
|
|
146
|
+
stmt = (
|
|
147
|
+
select(NotePageContentDO)
|
|
148
|
+
.where(NotePageContentDO.file_id == file_id)
|
|
149
|
+
.order_by(NotePageContentDO.page_index)
|
|
150
|
+
)
|
|
151
|
+
result = await session.execute(stmt)
|
|
152
|
+
pages = result.scalars().all()
|
|
153
|
+
|
|
154
|
+
if not pages:
|
|
155
|
+
logger.info(f"No OCR pages found for file {file_id}. Skipping summary.")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Format transcript with Page markers and inferred dates
|
|
159
|
+
text_parts = []
|
|
160
|
+
for p in pages:
|
|
161
|
+
if p.text_content:
|
|
162
|
+
metadata = format_page_metadata(
|
|
163
|
+
page_index=p.page_index,
|
|
164
|
+
page_id=p.page_id or "",
|
|
165
|
+
file_name=file_do.file_name,
|
|
166
|
+
notebook_create_time=file_do.create_time,
|
|
167
|
+
include_section_divider=True,
|
|
168
|
+
)
|
|
169
|
+
text_parts.append(f"{metadata}\n{p.text_content}")
|
|
170
|
+
|
|
171
|
+
full_text = "\n\n".join(text_parts)
|
|
172
|
+
|
|
173
|
+
# 4. Generate Transcript Summary (Preserve existing functionality)
|
|
174
|
+
# Store the raw aggregated text as a 'transcript' summary type first
|
|
175
|
+
# This is a good baseline to have.
|
|
176
|
+
await self._upsert_summary(
|
|
177
|
+
user_email,
|
|
178
|
+
AddSummaryDTO(
|
|
179
|
+
file_id=file_id,
|
|
180
|
+
unique_identifier=transcript_uuid,
|
|
181
|
+
content=full_text,
|
|
182
|
+
data_source="OCR",
|
|
183
|
+
source_path=file_do.file_name,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# 5. Generate AI Summary using Gemini
|
|
188
|
+
# Determine prompt based on filename/type
|
|
189
|
+
custom_type = Path(file_do.file_name).stem.lower()
|
|
190
|
+
|
|
191
|
+
# Load Prompt using specialized logic: Common + (Custom OR Default)
|
|
192
|
+
prompt_template = PROMPT_LOADER.get_prompt(
|
|
193
|
+
PromptId.SUMMARY_GENERATION, custom_type=custom_type
|
|
194
|
+
)
|
|
195
|
+
prompt = f"{prompt_template}\n\nTRANSCRIPT:\n{full_text}"
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
response = await self.gemini_service.generate_content(
|
|
199
|
+
model=self.config.gemini_ocr_model,
|
|
200
|
+
contents=prompt,
|
|
201
|
+
config={
|
|
202
|
+
"response_mime_type": "application/json",
|
|
203
|
+
"response_json_schema": build_json_schema(
|
|
204
|
+
SummaryResponse
|
|
205
|
+
).to_dict(),
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to generate AI summary for file {file_id}: {e}")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# Parse JSON response
|
|
213
|
+
ai_summary = "No summary generated."
|
|
214
|
+
metadata_str = None
|
|
215
|
+
|
|
216
|
+
if response.text:
|
|
217
|
+
try:
|
|
218
|
+
data = json.loads(response.text)
|
|
219
|
+
segments_data = data.get("segments", [])
|
|
220
|
+
|
|
221
|
+
# Format segments into Markdown and collect metadata
|
|
222
|
+
summary_parts = []
|
|
223
|
+
all_extracted_dates = []
|
|
224
|
+
|
|
225
|
+
# Check for page citations and build clean segment list for metadata
|
|
226
|
+
clean_segments = []
|
|
227
|
+
|
|
228
|
+
for seg in segments_data:
|
|
229
|
+
date_range = seg.get("date_range", "Unknown Date")
|
|
230
|
+
text = seg.get("summary", "")
|
|
231
|
+
dates = seg.get("extracted_dates", [])
|
|
232
|
+
page_refs = seg.get("page_refs", [])
|
|
233
|
+
|
|
234
|
+
# Markdown Formatting
|
|
235
|
+
header = f"## {date_range}"
|
|
236
|
+
# Add page citations to markdown too for readability
|
|
237
|
+
if page_refs:
|
|
238
|
+
# e.g. (Pages 45, 46)
|
|
239
|
+
pages_str = ", ".join(str(p) for p in page_refs)
|
|
240
|
+
header += f" (Pages {pages_str})"
|
|
241
|
+
|
|
242
|
+
summary_parts.append(f"{header}\n{text}")
|
|
243
|
+
all_extracted_dates.extend(dates)
|
|
244
|
+
|
|
245
|
+
clean_segments.append(
|
|
246
|
+
{
|
|
247
|
+
"date_range": date_range,
|
|
248
|
+
"summary": text, # Optional: could truncate or omit to save space
|
|
249
|
+
"extracted_dates": dates,
|
|
250
|
+
"page_refs": page_refs,
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if summary_parts:
|
|
255
|
+
ai_summary = "\n\n".join(summary_parts)
|
|
256
|
+
|
|
257
|
+
# Construct rich metadata for retrieval
|
|
258
|
+
if clean_segments:
|
|
259
|
+
meta_obj = {METADATA_SEGMENTS: clean_segments}
|
|
260
|
+
metadata_str = json.dumps(meta_obj)
|
|
261
|
+
logger.info(
|
|
262
|
+
f"Generated metadata for file {file_id}: segments={len(clean_segments)}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
except json.JSONDecodeError:
|
|
266
|
+
logger.error(f"Failed to parse JSON response for file {file_id}")
|
|
267
|
+
ai_summary = response.text
|
|
268
|
+
|
|
269
|
+
await self._upsert_summary(
|
|
270
|
+
user_email,
|
|
271
|
+
AddSummaryDTO(
|
|
272
|
+
file_id=file_id,
|
|
273
|
+
unique_identifier=summary_uuid,
|
|
274
|
+
content=ai_summary,
|
|
275
|
+
data_source="GEMINI",
|
|
276
|
+
source_path=file_do.file_name,
|
|
277
|
+
metadata=metadata_str,
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def _upsert_summary(self, user_email: str, dto: AddSummaryDTO) -> None:
|
|
282
|
+
"""Helper to either add or update a summary based on its unique identifier."""
|
|
283
|
+
if not dto.unique_identifier:
|
|
284
|
+
logger.error("Cannot upsert summary without a unique identifier")
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
existing = await self.summary_service.get_summary_by_uuid(
|
|
288
|
+
user_email, dto.unique_identifier
|
|
289
|
+
)
|
|
290
|
+
if existing and existing.id is not None:
|
|
291
|
+
await self.summary_service.update_summary(
|
|
292
|
+
user_email,
|
|
293
|
+
UpdateSummaryDTO(
|
|
294
|
+
id=existing.id,
|
|
295
|
+
content=dto.content,
|
|
296
|
+
data_source=dto.data_source,
|
|
297
|
+
source_path=dto.source_path,
|
|
298
|
+
metadata=dto.metadata,
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
await self.summary_service.add_summary(user_email, dto)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import importlib.resources
|
|
3
|
+
import logging
|
|
4
|
+
from importlib.resources.abc import Traversable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Optional, Union
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
RESOURCES_DIR = importlib.resources.files("supernote.server") / "resources" / "prompts"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PromptId(str, enum.Enum):
|
|
14
|
+
OCR_TRANSCRIPTION = "ocr_transcription"
|
|
15
|
+
SUMMARY_GENERATION = "summary_generation"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CATEGORY_MAP = {
|
|
19
|
+
"ocr": PromptId.OCR_TRANSCRIPTION.value,
|
|
20
|
+
"summary": PromptId.SUMMARY_GENERATION.value,
|
|
21
|
+
}
|
|
22
|
+
COMMON = "common"
|
|
23
|
+
DEFAULT = "default"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PromptLoader:
|
|
27
|
+
"""Service to load and manage externalized prompts."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self, resources_dir: Optional[Union[Path, Traversable]] = None
|
|
31
|
+
) -> None:
|
|
32
|
+
self.resources_dir = resources_dir or RESOURCES_DIR
|
|
33
|
+
# Map: prompt_id -> (type -> prompt_text)
|
|
34
|
+
# type can be "common", "default", or specific custom types like "monthly"
|
|
35
|
+
self.prompts: Dict[str, Dict[str, str]] = {}
|
|
36
|
+
self._load_prompts()
|
|
37
|
+
|
|
38
|
+
def _load_prompts(self) -> None:
|
|
39
|
+
"""Load all prompts from the resources directory into memory."""
|
|
40
|
+
if not self.resources_dir.is_dir():
|
|
41
|
+
logger.warning(f"Prompts directory not found at {self.resources_dir}")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Initialize dicts for known prompt IDs
|
|
45
|
+
for pid in PromptId:
|
|
46
|
+
self.prompts[pid.value] = {}
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
# Map categories to PromptId
|
|
50
|
+
for category, prompt_id in CATEGORY_MAP.items():
|
|
51
|
+
category_dir = self.resources_dir / category
|
|
52
|
+
if not category_dir.is_dir():
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
if prompt_id not in self.prompts:
|
|
56
|
+
self.prompts[prompt_id] = {}
|
|
57
|
+
|
|
58
|
+
# 1. Load Common Prompts (Always On)
|
|
59
|
+
common_dir = category_dir / COMMON
|
|
60
|
+
if common_dir.is_dir():
|
|
61
|
+
common_text = self._read_prompts_from_dir(common_dir)
|
|
62
|
+
if common_text:
|
|
63
|
+
self.prompts[prompt_id][COMMON] = common_text
|
|
64
|
+
|
|
65
|
+
# 2. Load Default Prompts
|
|
66
|
+
default_dir = category_dir / DEFAULT
|
|
67
|
+
if default_dir.is_dir():
|
|
68
|
+
default_text = self._read_prompts_from_dir(default_dir)
|
|
69
|
+
if default_text:
|
|
70
|
+
self.prompts[prompt_id][DEFAULT] = default_text
|
|
71
|
+
|
|
72
|
+
# 3. Load Custom Prompts (sub-directories)
|
|
73
|
+
for item in category_dir.iterdir():
|
|
74
|
+
if item.is_dir() and item.name not in [COMMON, DEFAULT]:
|
|
75
|
+
custom_type = item.name.lower()
|
|
76
|
+
custom_text = self._read_prompts_from_dir(item)
|
|
77
|
+
if custom_text:
|
|
78
|
+
self.prompts[prompt_id][custom_type] = custom_text
|
|
79
|
+
|
|
80
|
+
logger.info(f"Loaded prompts from {self.resources_dir}")
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Failed to load prompts from {self.resources_dir}: {e}")
|
|
84
|
+
|
|
85
|
+
def _read_prompts_from_dir(self, directory: Union[Path, Traversable]) -> str:
|
|
86
|
+
"""Read and concatenate all .md files in a directory."""
|
|
87
|
+
prompts = []
|
|
88
|
+
# Use iterdir instead of glob for Traversable compatibility
|
|
89
|
+
files = sorted(
|
|
90
|
+
[f for f in directory.iterdir() if f.name.endswith(".md")],
|
|
91
|
+
key=lambda x: x.name,
|
|
92
|
+
)
|
|
93
|
+
for file_path in files:
|
|
94
|
+
if file_path.is_file():
|
|
95
|
+
prompts.append(file_path.read_text(encoding="utf-8").strip())
|
|
96
|
+
return "\n\n".join(prompts)
|
|
97
|
+
|
|
98
|
+
def get_prompt(self, prompt_id: str, custom_type: Optional[str] = None) -> str:
|
|
99
|
+
"""Retrieve a prompt by its ID, optionally overridden by a custom type.
|
|
100
|
+
|
|
101
|
+
Logic: Common + (Custom if exists else Default)
|
|
102
|
+
"""
|
|
103
|
+
if prompt_id not in self.prompts:
|
|
104
|
+
raise ValueError(f"Prompt ID '{prompt_id}' not found.")
|
|
105
|
+
|
|
106
|
+
type_map = self.prompts[prompt_id]
|
|
107
|
+
|
|
108
|
+
parts = []
|
|
109
|
+
|
|
110
|
+
# 1. Add Common
|
|
111
|
+
if COMMON in type_map:
|
|
112
|
+
parts.append(type_map[COMMON])
|
|
113
|
+
|
|
114
|
+
# 2. Add Specific (Custom or Default)
|
|
115
|
+
specific_prompt = None
|
|
116
|
+
if custom_type and custom_type in type_map:
|
|
117
|
+
logger.info(f"Using custom prompt '{custom_type}' for {prompt_id}")
|
|
118
|
+
specific_prompt = type_map[custom_type]
|
|
119
|
+
elif DEFAULT in type_map:
|
|
120
|
+
specific_prompt = type_map[DEFAULT]
|
|
121
|
+
|
|
122
|
+
if specific_prompt:
|
|
123
|
+
parts.append(specific_prompt)
|
|
124
|
+
elif not parts:
|
|
125
|
+
# If no common and no specific/default, that's an issue
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"No prompt content found for '{prompt_id}' (Custom: {custom_type})"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return "\n\n".join(parts)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
PROMPT_LOADER = PromptLoader()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import delete, select, update
|
|
5
|
+
|
|
6
|
+
from supernote.server.db.models.schedule import ScheduleTaskDO, ScheduleTaskGroupDO
|
|
7
|
+
from supernote.server.db.session import DatabaseSessionManager
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
MAX_TITLE_LENGTH = 255
|
|
12
|
+
MAX_DETAIL_LENGTH = 1 * 1024 * 1024 # 1MB
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScheduleService:
|
|
16
|
+
"""Schedule service."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, session_manager: DatabaseSessionManager):
|
|
19
|
+
"""Initialize the schedule service."""
|
|
20
|
+
self.session_manager = session_manager
|
|
21
|
+
|
|
22
|
+
# Task Group Operations
|
|
23
|
+
|
|
24
|
+
async def create_group(self, user_id: int, title: str) -> ScheduleTaskGroupDO:
|
|
25
|
+
"""Create a new task group."""
|
|
26
|
+
if len(title) > MAX_TITLE_LENGTH:
|
|
27
|
+
raise ValueError("Title is too long")
|
|
28
|
+
async with self.session_manager.session() as session:
|
|
29
|
+
group = ScheduleTaskGroupDO(user_id=user_id, title=title)
|
|
30
|
+
session.add(group)
|
|
31
|
+
# Flush to generate ID
|
|
32
|
+
await session.flush()
|
|
33
|
+
# Commit to persist
|
|
34
|
+
await session.commit()
|
|
35
|
+
# Refresh to get defaults if any
|
|
36
|
+
await session.refresh(group)
|
|
37
|
+
return group
|
|
38
|
+
|
|
39
|
+
async def list_groups(self, user_id: int) -> list[ScheduleTaskGroupDO]:
|
|
40
|
+
"""List all task groups for a user."""
|
|
41
|
+
async with self.session_manager.session() as session:
|
|
42
|
+
stmt = (
|
|
43
|
+
select(ScheduleTaskGroupDO)
|
|
44
|
+
.where(ScheduleTaskGroupDO.user_id == user_id)
|
|
45
|
+
.order_by(ScheduleTaskGroupDO.create_time.desc())
|
|
46
|
+
)
|
|
47
|
+
result = await session.execute(stmt)
|
|
48
|
+
return list(result.scalars().all())
|
|
49
|
+
|
|
50
|
+
async def delete_group(self, user_id: int, group_id: int) -> bool:
|
|
51
|
+
"""Delete a task group. Returns True if found and deleted."""
|
|
52
|
+
# TODO: Handle cascade delete of tasks?
|
|
53
|
+
# For now, simplistic delete.
|
|
54
|
+
async with self.session_manager.session() as session:
|
|
55
|
+
stmt = delete(ScheduleTaskGroupDO).where(
|
|
56
|
+
ScheduleTaskGroupDO.user_id == user_id,
|
|
57
|
+
ScheduleTaskGroupDO.task_list_id == group_id,
|
|
58
|
+
)
|
|
59
|
+
result = await session.execute(stmt)
|
|
60
|
+
await session.commit()
|
|
61
|
+
return bool(result.rowcount > 0) # type: ignore[attr-defined]
|
|
62
|
+
|
|
63
|
+
# Task Operations
|
|
64
|
+
|
|
65
|
+
async def create_task(
|
|
66
|
+
self,
|
|
67
|
+
user_id: int,
|
|
68
|
+
group_id: int,
|
|
69
|
+
title: str,
|
|
70
|
+
detail: str = "",
|
|
71
|
+
status: str = "needsAction",
|
|
72
|
+
importance: str | None = None,
|
|
73
|
+
due_time: int | None = None,
|
|
74
|
+
recurrence: str | None = None,
|
|
75
|
+
is_reminder_on: bool = False,
|
|
76
|
+
) -> ScheduleTaskDO:
|
|
77
|
+
"""Create a new task."""
|
|
78
|
+
if len(title) > MAX_TITLE_LENGTH:
|
|
79
|
+
raise ValueError("Title is too long")
|
|
80
|
+
if len(detail) > MAX_DETAIL_LENGTH:
|
|
81
|
+
raise ValueError("Detail is too long")
|
|
82
|
+
async with self.session_manager.session() as session:
|
|
83
|
+
task = ScheduleTaskDO(
|
|
84
|
+
user_id=user_id,
|
|
85
|
+
task_list_id=group_id,
|
|
86
|
+
title=title,
|
|
87
|
+
detail=detail,
|
|
88
|
+
status=status,
|
|
89
|
+
importance=importance,
|
|
90
|
+
due_time=due_time,
|
|
91
|
+
recurrence=recurrence,
|
|
92
|
+
is_reminder_on=is_reminder_on,
|
|
93
|
+
)
|
|
94
|
+
session.add(task)
|
|
95
|
+
await session.flush()
|
|
96
|
+
await session.commit()
|
|
97
|
+
await session.refresh(task)
|
|
98
|
+
return task
|
|
99
|
+
|
|
100
|
+
async def list_tasks(
|
|
101
|
+
self,
|
|
102
|
+
user_id: int,
|
|
103
|
+
group_id: int | None = None,
|
|
104
|
+
) -> list[ScheduleTaskDO]:
|
|
105
|
+
"""List tasks for a user, optionally filtered by group."""
|
|
106
|
+
async with self.session_manager.session() as session:
|
|
107
|
+
query = select(ScheduleTaskDO).where(ScheduleTaskDO.user_id == user_id)
|
|
108
|
+
if group_id is not None:
|
|
109
|
+
query = query.where(ScheduleTaskDO.task_list_id == group_id)
|
|
110
|
+
|
|
111
|
+
query = query.order_by(ScheduleTaskDO.create_time.desc())
|
|
112
|
+
result = await session.execute(query)
|
|
113
|
+
return list(result.scalars().all())
|
|
114
|
+
|
|
115
|
+
async def update_task(
|
|
116
|
+
self, user_id: int, task_id: int, **kwargs: Any
|
|
117
|
+
) -> Optional[ScheduleTaskDO]:
|
|
118
|
+
"""Update a task."""
|
|
119
|
+
# Clean kwargs to only allow update of specific fields?
|
|
120
|
+
# For simplicity, we assume caller passes valid fields that match DO columns.
|
|
121
|
+
allowed_fields = {
|
|
122
|
+
"title",
|
|
123
|
+
"detail",
|
|
124
|
+
"status",
|
|
125
|
+
"importance",
|
|
126
|
+
"due_time",
|
|
127
|
+
"completed_time",
|
|
128
|
+
"recurrence",
|
|
129
|
+
"is_reminder_on",
|
|
130
|
+
"task_list_id",
|
|
131
|
+
}
|
|
132
|
+
updates = {k: v for k, v in kwargs.items() if k in allowed_fields}
|
|
133
|
+
|
|
134
|
+
async with self.session_manager.session() as session:
|
|
135
|
+
stmt = (
|
|
136
|
+
update(ScheduleTaskDO)
|
|
137
|
+
.where(
|
|
138
|
+
ScheduleTaskDO.user_id == user_id, ScheduleTaskDO.task_id == task_id
|
|
139
|
+
)
|
|
140
|
+
.values(**updates)
|
|
141
|
+
.execution_options(synchronize_session="fetch")
|
|
142
|
+
)
|
|
143
|
+
await session.execute(stmt)
|
|
144
|
+
await session.commit()
|
|
145
|
+
|
|
146
|
+
# Retrieve updated
|
|
147
|
+
stmt_get = select(ScheduleTaskDO).where(
|
|
148
|
+
ScheduleTaskDO.user_id == user_id, ScheduleTaskDO.task_id == task_id
|
|
149
|
+
)
|
|
150
|
+
result = await session.execute(stmt_get)
|
|
151
|
+
return result.scalar_one_or_none()
|
|
152
|
+
|
|
153
|
+
async def delete_task(self, user_id: int, task_id: int) -> bool:
|
|
154
|
+
"""Delete a task."""
|
|
155
|
+
async with self.session_manager.session() as session:
|
|
156
|
+
stmt = delete(ScheduleTaskDO).where(
|
|
157
|
+
ScheduleTaskDO.user_id == user_id, ScheduleTaskDO.task_id == task_id
|
|
158
|
+
)
|
|
159
|
+
result = await session.execute(stmt)
|
|
160
|
+
await session.commit()
|
|
161
|
+
return bool(result.rowcount > 0) # type: ignore[attr-defined]
|