matsuap commited on
Commit
b07f5e4
·
verified ·
1 Parent(s): 68747d1

Upload folder using huggingface_hub

Browse files
api/canvas.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Response
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Optional
4
+ import logging
5
+ from datetime import datetime
6
+
7
+ from core.database import get_db
8
+ from models import db_models
9
+ from models.schemas import CanvasCreateRequest, CanvasEditRequest, CanvasResponse
10
+ from services.canvas_service import canvas_service
11
+ from api.auth import get_current_user
12
+
13
+ router = APIRouter(prefix="/api/canvas", tags=["Canvas - Collaborative Editing"])
14
+ logger = logging.getLogger(__name__)
15
+
16
+ @router.post("/create", response_model=CanvasResponse)
17
+ async def create_canvas(
18
+ request: CanvasCreateRequest,
19
+ current_user: db_models.User = Depends(get_current_user),
20
+ db: Session = Depends(get_db)
21
+ ):
22
+ """
23
+ Create a new Canvas entry.
24
+ Pass file_key to extract text and generate an initial Markdown summary.
25
+ """
26
+ try:
27
+ source_id = None
28
+ if request.file_key:
29
+ source = db.query(db_models.Source).filter(
30
+ db_models.Source.s3_key == request.file_key,
31
+ db_models.Source.user_id == current_user.id
32
+ ).first()
33
+ if not source:
34
+ raise HTTPException(status_code=404, detail="Source file not found")
35
+ source_id = source.id
36
+
37
+ # Create initial record
38
+ title = request.title or f"Canvas {datetime.now().strftime('%Y-%m-%d %H:%M')}"
39
+ db_canvas = db_models.Canvas(
40
+ title=title,
41
+ user_id=current_user.id,
42
+ source_id=source_id,
43
+ status="processing"
44
+ )
45
+ db.add(db_canvas)
46
+ db.commit()
47
+ db.refresh(db_canvas)
48
+
49
+ try:
50
+ # Generate summary
51
+ content = await canvas_service.generate_canvas_summary(
52
+ file_key=request.file_key,
53
+ text_input=request.text_input
54
+ )
55
+
56
+ db_canvas.text = content
57
+ db_canvas.status = "completed"
58
+ db.commit()
59
+ db.refresh(db_canvas)
60
+
61
+ return db_canvas
62
+
63
+ except Exception as e:
64
+ db_canvas.status = "failed"
65
+ db_canvas.error_message = str(e)
66
+ db.commit()
67
+ raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
68
+
69
+ except HTTPException:
70
+ raise
71
+ except Exception as e:
72
+ logger.error(f"Error creating canvas: {e}")
73
+ raise HTTPException(status_code=500, detail=str(e))
74
+
75
+ @router.get("/", response_model=List[CanvasResponse])
76
+ async def list_canvases(
77
+ current_user: db_models.User = Depends(get_current_user),
78
+ db: Session = Depends(get_db)
79
+ ):
80
+ """List all canvases for the current user."""
81
+ return db.query(db_models.Canvas).filter(db_models.Canvas.user_id == current_user.id).all()
82
+
83
+ @router.get("/{canvas_id}", response_model=CanvasResponse)
84
+ async def get_canvas(
85
+ canvas_id: int,
86
+ current_user: db_models.User = Depends(get_current_user),
87
+ db: Session = Depends(get_db)
88
+ ):
89
+ """Get a specific canvas by ID."""
90
+ canvas = db.query(db_models.Canvas).filter(
91
+ db_models.Canvas.id == canvas_id,
92
+ db_models.Canvas.user_id == current_user.id
93
+ ).first()
94
+ if not canvas:
95
+ raise HTTPException(status_code=404, detail="Canvas not found")
96
+ return canvas
97
+
98
+ @router.post("/{canvas_id}/update", response_model=CanvasResponse)
99
+ async def update_canvas_text(
100
+ canvas_id: int,
101
+ request: CanvasEditRequest,
102
+ current_user: db_models.User = Depends(get_current_user),
103
+ db: Session = Depends(get_db)
104
+ ):
105
+ """
106
+ Manually update the text of an existing canvas.
107
+ """
108
+ db_canvas = db.query(db_models.Canvas).filter(
109
+ db_models.Canvas.id == canvas_id,
110
+ db_models.Canvas.user_id == current_user.id
111
+ ).first()
112
+
113
+ if not db_canvas:
114
+ raise HTTPException(status_code=404, detail="Canvas not found")
115
+
116
+ try:
117
+ db_canvas.text = request.text
118
+ db_canvas.status = "completed"
119
+ db.commit()
120
+ db.refresh(db_canvas)
121
+
122
+ return db_canvas
123
+
124
+ except Exception as e:
125
+ logger.error(f"Error updating canvas: {e}")
126
+ raise HTTPException(status_code=500, detail=str(e))
127
+
128
+
129
+ @router.delete("/{canvas_id}")
130
+ async def delete_canvas(
131
+ canvas_id: int,
132
+ current_user: db_models.User = Depends(get_current_user),
133
+ db: Session = Depends(get_db)
134
+ ):
135
+ """Delete a canvas."""
136
+ db_canvas = db.query(db_models.Canvas).filter(
137
+ db_models.Canvas.id == canvas_id,
138
+ db_models.Canvas.user_id == current_user.id
139
+ ).first()
140
+
141
+ if not db_canvas:
142
+ raise HTTPException(status_code=404, detail="Canvas not found")
143
+
144
+ db.delete(db_canvas)
145
+ db.commit()
146
+ return {"message": "Canvas deleted successfully"}
147
+
core/database.py CHANGED
@@ -11,7 +11,7 @@ SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL or "sqlite:///./temp.db"
11
  engine = create_engine(
12
  SQLALCHEMY_DATABASE_URL,
13
  pool_pre_ping=True, # Check connection health before every query
14
- pool_recycle=120, # Refresh connections every 2 minutes (more aggressive)
15
  pool_timeout=30, # Wait up to 30 seconds for a connection
16
  pool_size=15, # Maintain a slightly larger pool
17
  max_overflow=25, # Allow more overflow if busy
 
11
  engine = create_engine(
12
  SQLALCHEMY_DATABASE_URL,
13
  pool_pre_ping=True, # Check connection health before every query
14
+ pool_recycle=1800, # 30 minutes (best practice)
15
  pool_timeout=30, # Wait up to 30 seconds for a connection
16
  pool_size=15, # Maintain a slightly larger pool
17
  max_overflow=25, # Allow more overflow if busy
core/prompts.py CHANGED
@@ -327,7 +327,12 @@ def get_quiz_system_prompt(language: str = "Japanese") -> str:
327
  {
328
  "question": "問題文",
329
  "hint": "ヒント",
330
- "choices": { "1": "選択肢1", "2": "選択肢2", "3": "選択肢3", "4": "選択肢4" },
 
 
 
 
 
331
  "answer": "1|2|3|4 のいずれか",
332
  "explanation": "正解の詳細な説明"
333
  }
@@ -357,7 +362,12 @@ Output format (and nothing else):
357
  {
358
  "question": "Question",
359
  "hint": "Hint",
360
- "choices": { "1": "Choice 1", "2": "Choice 2", "3": "Choice 3", "4": "Choice 4" },
 
 
 
 
 
361
  "answer": "1|2|3|4",
362
  "explanation": "Detailed reasoning for why this is correct"
363
  }
@@ -574,3 +584,28 @@ def get_outline_prompt(template_yaml_text: str, source_text: str, custom_prompt:
574
  + ("## 追加指示\n\n" + extra + "\n\n" if extra else "")
575
  + "## 入力\n\n* TEMPLATE_YAML:\n\n" + template_yaml_text + "\n\n* SOURCE_TEXT:\n\n" + source_text
576
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  {
328
  "question": "問題文",
329
  "hint": "ヒント",
330
+ "choices": [
331
+ { "value": "1", "label": "選択肢1" },
332
+ { "value": "2", "label": "選択肢2" },
333
+ { "value": "3", "label": "選択肢3" },
334
+ { "value": "4", "label": "選択肢4" }
335
+ ],
336
  "answer": "1|2|3|4 のいずれか",
337
  "explanation": "正解の詳細な説明"
338
  }
 
362
  {
363
  "question": "Question",
364
  "hint": "Hint",
365
+ "choices": [
366
+ { "value": "1", "label": "Choice 1" },
367
+ { "value": "2", "label": "Choice 2" },
368
+ { "value": "3", "label": "Choice 3" },
369
+ { "value": "4", "label": "Choice 4" }
370
+ ],
371
  "answer": "1|2|3|4",
372
  "explanation": "Detailed reasoning for why this is correct"
373
  }
 
584
  + ("## 追加指示\n\n" + extra + "\n\n" if extra else "")
585
  + "## 入力\n\n* TEMPLATE_YAML:\n\n" + template_yaml_text + "\n\n* SOURCE_TEXT:\n\n" + source_text
586
  )
587
+
588
+ def get_canvas_system_prompt() -> str:
589
+ return """You are a professional content editor and writing assistant. Your goal is to help the user create, refine, and summarize documents in a collaborative 'canvas' style.
590
+
591
+ INSTRUCTIONS:
592
+ 1. When creating a summary, focus on clarity, accuracy, and structure.
593
+ 2. Use Markdown formatting for headings, bullet points, and emphasis.
594
+ 3. Ensure the content is easy to read and logically organized.
595
+ 4. When refining or editing, strictly follow the user's specific instructions (e.g., tone change, expansion, shortening).
596
+ 5. Output should be ONLY the Markdown content. Do not include any other text or explanation.
597
+ 6. CRITICAL: Do NOT escape any characters like quotes or newlines. Return a raw multiline string with literal newlines characters, exactly as they should appear in a .md file. Do not wrap the output in a JSON object or string."""
598
+
599
+ def get_canvas_edit_prompt(instruction: str, current_content: str) -> str:
600
+ return f"""The user wants you to edit the following document based on this instruction: "{instruction}"
601
+
602
+ CURRENT CONTENT:
603
+ ---
604
+ {current_content}
605
+ ---
606
+
607
+ Your task:
608
+ - Apply the user's instruction to the document.
609
+ - Preserve the overall meaning unless asked to change it.
610
+ - Maintain the Markdown structure.
611
+ - Return ONLY the updated Markdown content. Do not include any other text or explanation."""
main.py CHANGED
@@ -1,7 +1,7 @@
1
  from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from core.database import init_db
4
- from api import auth, sources, podcast, flashcards, mindmaps, quizzes, reports, video_generator, rag, chat, websocket_routes
5
 
6
  # Initialize Database Tables
7
  init_db()
@@ -32,6 +32,7 @@ app.include_router(reports.router)
32
  app.include_router(video_generator.router)
33
  app.include_router(rag.router)
34
  app.include_router(chat.router)
 
35
  app.include_router(websocket_routes.router) # WebSocket endpoints for real-time progress
36
 
37
  @app.get("/")
 
1
  from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from core.database import init_db
4
+ from api import auth, sources, podcast, flashcards, mindmaps, quizzes, reports, video_generator, rag, chat, websocket_routes, canvas
5
 
6
  # Initialize Database Tables
7
  init_db()
 
32
  app.include_router(video_generator.router)
33
  app.include_router(rag.router)
34
  app.include_router(chat.router)
35
+ app.include_router(canvas.router)
36
  app.include_router(websocket_routes.router) # WebSocket endpoints for real-time progress
37
 
38
  @app.get("/")
models/db_models.py CHANGED
@@ -21,6 +21,7 @@ class User(Base):
21
  reports = relationship("Report", back_populates="owner")
22
  video_summaries = relationship("VideoSummary", back_populates="owner")
23
  rag_documents = relationship("RAGDocument", back_populates="owner")
 
24
  chat_messages = relationship("ChatMessage", back_populates="owner", cascade="all, delete-orphan")
25
 
26
  class Source(Base):
@@ -42,6 +43,7 @@ class Source(Base):
42
  reports = relationship("Report", back_populates="source")
43
  video_summaries = relationship("VideoSummary", back_populates="source")
44
  rag_documents = relationship("RAGDocument", back_populates="source")
 
45
 
46
  class Podcast(Base):
47
  __tablename__ = "podcasts"
@@ -229,4 +231,24 @@ class ChatMessage(Base):
229
  content = Column(UnicodeText, nullable=False)
230
  created_at = Column(DateTime(timezone=True), server_default=func.now())
231
 
232
- owner = relationship("User", back_populates="chat_messages")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  reports = relationship("Report", back_populates="owner")
22
  video_summaries = relationship("VideoSummary", back_populates="owner")
23
  rag_documents = relationship("RAGDocument", back_populates="owner")
24
+ canvases = relationship("Canvas", back_populates="owner")
25
  chat_messages = relationship("ChatMessage", back_populates="owner", cascade="all, delete-orphan")
26
 
27
  class Source(Base):
 
43
  reports = relationship("Report", back_populates="source")
44
  video_summaries = relationship("VideoSummary", back_populates="source")
45
  rag_documents = relationship("RAGDocument", back_populates="source")
46
+ canvases = relationship("Canvas", back_populates="source")
47
 
48
  class Podcast(Base):
49
  __tablename__ = "podcasts"
 
231
  content = Column(UnicodeText, nullable=False)
232
  created_at = Column(DateTime(timezone=True), server_default=func.now())
233
 
234
+ owner = relationship("User", back_populates="chat_messages")
235
+
236
+ class Canvas(Base) :
237
+ __tablename__ = "canvases"
238
+
239
+ id = Column(Integer, primary_key=True, index=True)
240
+ title = Column(Unicode(255))
241
+ text = Column(UnicodeText, nullable=True) # Markdown text
242
+ user_id = Column(Integer, ForeignKey("users.id"))
243
+ source_id = Column(Integer, ForeignKey("sources.id"), nullable=True)
244
+ status = Column(String(50), default="processing")
245
+ error_message = Column(UnicodeText, nullable=True)
246
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
247
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
248
+
249
+ owner = relationship("User", back_populates="canvases")
250
+ source = relationship("Source", back_populates="canvases")
251
+
252
+ @property
253
+ def parent_file_key(self):
254
+ return self.source.s3_key if self.source else None
models/schemas.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel, EmailStr
2
  from typing import List, Optional, Dict, Any
3
  from datetime import datetime
4
 
@@ -149,14 +149,26 @@ class QuizGenerateRequest(BaseModel):
149
  language: str = "English"
150
  count: str = "STANDARD" # FEWER, STANDARD, MORE
151
 
 
 
 
 
152
  class QuizQuestionResponse(BaseModel):
153
  id: int
154
  question: str
155
  hint: Optional[str]
156
- choices: dict
157
  answer: str
158
  explanation: Optional[str]
159
 
 
 
 
 
 
 
 
 
160
  class Config:
161
  from_attributes = True
162
 
@@ -276,3 +288,27 @@ class ChatMessageResponse(BaseModel):
276
 
277
  class Config:
278
  from_attributes = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr, field_validator
2
  from typing import List, Optional, Dict, Any
3
  from datetime import datetime
4
 
 
149
  language: str = "English"
150
  count: str = "STANDARD" # FEWER, STANDARD, MORE
151
 
152
+ class QuizChoice(BaseModel) :
153
+ value: str
154
+ label: str
155
+
156
  class QuizQuestionResponse(BaseModel):
157
  id: int
158
  question: str
159
  hint: Optional[str]
160
+ choices: List[QuizChoice]
161
  answer: str
162
  explanation: Optional[str]
163
 
164
+ @field_validator('choices', mode='before')
165
+ @classmethod
166
+ def validate_choices(cls, v):
167
+ if isinstance(v, dict):
168
+ # Convert {"1": "label1", "2": "label2"} to [{"value": "1", "label": "label1"}, ...]
169
+ return [{"value": key, "label": value} for key, value in v.items()]
170
+ return v
171
+
172
  class Config:
173
  from_attributes = True
174
 
 
288
 
289
  class Config:
290
  from_attributes = True
291
+
292
+ # Canvas Schemas
293
+ class CanvasCreateRequest(BaseModel):
294
+ file_key: Optional[str] = None
295
+ text_input: Optional[str] = None
296
+ title: Optional[str] = None
297
+
298
+ class CanvasEditRequest(BaseModel):
299
+ text: str # The manually edited markdown text
300
+
301
+
302
+ class CanvasResponse(BaseModel):
303
+ id: int
304
+ title: str
305
+ text: Optional[str]
306
+ status: str
307
+ error_message: Optional[str] = None
308
+ parent_file_id: Optional[int] = None
309
+ parent_file_key: Optional[str] = None
310
+ created_at: datetime
311
+ updated_at: datetime
312
+
313
+ class Config:
314
+ from_attributes = True
services/canvas_service.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import asyncio
5
+ import tempfile
6
+ import time
7
+ from typing import List, Dict, Optional, Any
8
+ import openai
9
+
10
+ from core.config import settings
11
+ from core.prompts import get_canvas_system_prompt, get_canvas_edit_prompt
12
+ from services.s3_service import s3_service
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class CanvasService:
17
+ def __init__(self):
18
+ self.openai_client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
19
+
20
+ async def generate_canvas_summary(
21
+ self,
22
+ file_key: Optional[str] = None,
23
+ text_input: Optional[str] = None
24
+ ) -> str:
25
+ """
26
+ Generates a Markdown summary of the provided content.
27
+ """
28
+ try:
29
+ system_prompt = get_canvas_system_prompt()
30
+ user_msg = "Please analyze the provided content and create a comprehensive, well-structured summary in Markdown format. Output ONLY the raw markdown content without code block markers."
31
+
32
+ if file_key:
33
+ # ... (downloading code unchanged)
34
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
35
+ tmp_path = tmp.name
36
+ tmp.close()
37
+
38
+ try:
39
+ await asyncio.to_thread(
40
+ s3_service.s3_client.download_file,
41
+ settings.AWS_S3_BUCKET,
42
+ file_key,
43
+ tmp_path
44
+ )
45
+
46
+ def upload_to_openai():
47
+ with open(tmp_path, "rb") as f:
48
+ return self.openai_client.files.create(file=f, purpose="assistants")
49
+
50
+ uploaded_file = await asyncio.to_thread(upload_to_openai)
51
+
52
+ messages = [
53
+ {"role": "system", "content": system_prompt},
54
+ {
55
+ "role": "user",
56
+ "content": [
57
+ {"type": "file", "file": {"file_id": uploaded_file.id}},
58
+ {"type": "text", "text": user_msg}
59
+ ]
60
+ }
61
+ ]
62
+
63
+ response = await asyncio.to_thread(
64
+ self.openai_client.chat.completions.create,
65
+ model="gpt-4o",
66
+ messages=messages,
67
+ temperature=0.7
68
+ )
69
+
70
+ await asyncio.to_thread(self.openai_client.files.delete, uploaded_file.id)
71
+ return self._clean_markdown_content(response.choices[0].message.content)
72
+
73
+ finally:
74
+ if os.path.exists(tmp_path):
75
+ await asyncio.to_thread(os.remove, tmp_path)
76
+
77
+ elif text_input:
78
+ messages = [
79
+ {"role": "system", "content": system_prompt},
80
+ {"role": "user", "content": f"{user_msg}\n\nCONTENT:\n{text_input}"}
81
+ ]
82
+
83
+ response = await asyncio.to_thread(
84
+ self.openai_client.chat.completions.create,
85
+ model="gpt-4o-mini",
86
+ messages=messages,
87
+ temperature=0.7
88
+ )
89
+ return self._clean_markdown_content(response.choices[0].message.content)
90
+
91
+ else:
92
+ raise ValueError("Either file_key or text_input must be provided")
93
+
94
+ except Exception as e:
95
+ logger.error(f"Canvas summary generation failed: {e}")
96
+ raise
97
+
98
+ async def refine_canvas(
99
+ self,
100
+ instruction: str,
101
+ current_content: str
102
+ ) -> str:
103
+ """
104
+ Refines existing Markdown content based on user instructions.
105
+ """
106
+ try:
107
+ system_prompt = get_canvas_system_prompt()
108
+ refine_prompt = get_canvas_edit_prompt(instruction, current_content)
109
+
110
+ messages = [
111
+ {"role": "system", "content": system_prompt},
112
+ {"role": "user", "content": refine_prompt}
113
+ ]
114
+
115
+ response = await asyncio.to_thread(
116
+ self.openai_client.chat.completions.create,
117
+ model="gpt-4o",
118
+ messages=messages,
119
+ temperature=0.7
120
+ )
121
+
122
+ return self._clean_markdown_content(response.choices[0].message.content)
123
+
124
+ except Exception as e:
125
+ logger.error(f"Canvas refinement failed: {e}")
126
+ raise
127
+
128
+ def _clean_markdown_content(self, content: str) -> str:
129
+ """Strips markdown code block markers (```markdown ... ```) if present."""
130
+ content = content.strip()
131
+ if content.startswith("```"):
132
+ # Remove opening marker
133
+ content = content.split("\n", 1)[-1] if "\n" in content else content[3:]
134
+ # Remove closing marker
135
+ if content.endswith("```"):
136
+ content = content[:-3]
137
+ return content.strip()
138
+
139
+ canvas_service = CanvasService()