File size: 19,146 Bytes
6733714
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Query
from fastapi.responses import StreamingResponse
from typing import List, Optional, Dict, Any
from bson import ObjectId
import os
import tempfile
import io
import logging
from docx import Document
from pydantic import BaseModel, Field
import numpy as np
from app.database import get_template_collection
from app.embeddings.embedder import embed_texts
from app.config import (
    LLM_PROVIDER, OPENAI_API_KEY, LLM_MODEL,
    ANTHROPIC_API_KEY, CLAUDE_MAIN_MODEL,
)
from app.security import get_current_user

logger = logging.getLogger(__name__)
router = APIRouter()

# ── LLM Client (uses configured provider) ────────────────────────────
def get_ai_client():
    if LLM_PROVIDER == "anthropic":
        import anthropic as _anthropic
        return _anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
    else:
        import openai as _openai
        return _openai.OpenAI(api_key=OPENAI_API_KEY)

# --- Pydantic Models for Validation ---

class ChatMessage(BaseModel):
    role: str # "user" or "assistant"
    content: str

class CustomizeRequest(BaseModel):
    user_context: str = Field(..., description="The user's specific case details and instructions for the AI.")
    messages: Optional[List[ChatMessage]] = Field(None, description="Optional chat history for multi-turn customization.")

class TemplateResponse(BaseModel):
    id: str
    title: str
    category: str
    sub_category: str
    stage: str
    keywords: List[str]
    summary: str
    content: Optional[str] = None 
    score: Optional[float] = None # Added for vector search ranking
    
    @classmethod
    def from_mongo(cls, doc: dict, include_content: bool = False, score: float = None):
        return cls(
            id=str(doc.get("_id")),
            title=doc.get("title", ""),
            category=doc.get("category", ""),
            sub_category=doc.get("sub_category", ""),
            stage=doc.get("stage", ""),
            keywords=doc.get("keywords", []),
            summary=doc.get("summary", ""),
            content=doc.get("content", "") if include_content else None,
            score=score
        )

class EnhanceRequest(BaseModel):
    current_content: str
    instructions: Optional[str] = None

# --- Endpoints ---

@router.get("/search")
async def search_templates(
    query: Optional[str] = None, 
    category: Optional[str] = None,
    sub_category: Optional[str] = None,
    stage: Optional[str] = None,
) -> Dict[str, Any]:
    """
    SOTA Semantic Search for Litigation Templates with Metadata Filtering.
    Generates query embeddings, filters metadata, and ranks documents by cosine similarity.
    """
    collection = get_template_collection()
    if collection is None:
        raise HTTPException(status_code=500, detail="Database connection failed")

    result_groups = []

    # Build DB filter query
    filter_query = {}
    if category and category != "All":
        filter_query["category"] = category
    if sub_category:
        filter_query["sub_category"] = sub_category
    if stage:
        filter_query["stage"] = stage

    if query:
        # 1. Generate Query Embedding
        query_vector = embed_texts([query])[0]
        
        # 2. Fetch templates with embeddings matching any category filter
        all_docs = list(collection.find(filter_query, {"embedding": 1, "title": 1, "category": 1, "sub_category": 1, "stage": 1, "keywords": 1, "summary": 1}))
        
        if not all_docs:
            return {"groups": []}

        # 3. Compute Hybrid Scores
        query_lower = query.lower()
        query_np = np.array(query_vector)
        
        scored_results = []
        for doc in all_docs:
            # a) Vector Similarity
            doc_embedding = doc.get("embedding")
            if not doc_embedding:
                continue
            doc_vector = np.array(doc_embedding)
            vector_score = float(np.dot(doc_vector, query_np))
            
            # b) Lexical (Title) Boost
            title = doc.get("title", "").lower()
            lexical_boost = 0.0
            
            # Exact Title Match (Massive Boost)
            if query_lower == title:
                lexical_boost = 1.0
            # Sequential Substring Match (Strong Boost)
            elif query_lower in title:
                lexical_boost = 0.7
            else:
                # Individual Word Matches (Granular Boost)
                q_words = [w for w in query_lower.split() if len(w) > 1]
                if q_words:
                    matches = sum(1 for w in q_words if w in title)
                    match_ratio = matches / len(q_words)
                    if match_ratio > 0:
                        # 0.2 baseline for any match + up to 0.3 for full coverage
                        lexical_boost = 0.2 + (match_ratio * 0.3)
                
            # c) Keyword Field Boost
            keywords = [k.lower() for k in doc.get("keywords", [])]
            if any(q_word in keywords for q_word in query_lower.split()):
                lexical_boost = max(lexical_boost, 0.4)

            # Combined Score (Prioritize Title/Keyword matches)
            # Weighting: 40% Semantic Vector, 60% Lexical/Names
            final_score = (vector_score * 0.4) + (lexical_boost * 0.6)
            
            if final_score > 0.15: # Slightly broader threshold for noisy queries
                scored_results.append((doc, final_score))
        
        # 4. Filter and Rank
        scored_results.sort(key=lambda x: x[1], reverse=True)
        top_matches = [TemplateResponse.from_mongo(doc, score=score).dict() for doc, score in scored_results[:15]]
        
        if top_matches:
            result_groups.append({
                "title": f"Best Matches for '{query}'",
                "type": "hero",
                "templates": top_matches[:1]
            })
            
            if len(top_matches) > 1:
                result_groups.append({
                    "title": "Relevant Litigation Strategies",
                    "type": "row",
                    "templates": top_matches[1:]
                })
        else:
            result_groups.append({
                "title": "No Matches Found",
                "type": "hero",
                "templates": []
            })
            
    else:
        # ── Grouped Home View with Category Filtering ──────────────────────────────────────────
        titles = {
            "ITC": "ITC Reversals & Compliance",
            "Appeal": "Appeals & Writ Formats",
            "Demand/Recovery": "Demand & Recovery Responses",
            "Refund": "Refund Claims & Formats",
            "Registration": "Registration & Cancellation Replies",
            "Notification": "GST Notifications (2025-26)",
            "Circular": "GST Circulars & Clarifications",
            "E-Way Bill": "E-Way Bill & Detention Replies",
            "Compliance & Returns": "GSTR Returns & Mismatch Strategies",
            "Demand & Penalty": "Penalty, Interest & Demand Responses",
            "FEMA": "FEMA Regulatory Compliance",
            "Direct Tax": "Direct Tax & TDS Guidelines",
            "Corporate": "Corporate Compliance Protocols"
        }

        if category and category != "All":
            docs = list(collection.find(filter_query).sort("ingested_at", -1).limit(100))
            if docs:
                result_groups.append({
                    "title": titles.get(category, f"{category} Intelligence"),
                    "type": "row",
                    "templates": [TemplateResponse.from_mongo(d).dict() for d in docs]
                })
        else:
            all_categories = collection.distinct("category")
            
            # 1. Start with a "Master Library" of most recent globally
            recent_all = list(collection.find(filter_query).sort("ingested_at", -1).limit(50))
            if recent_all:
                result_groups.append({
                    "title": "Master Litigation Library",
                    "type": "row",
                    "templates": [TemplateResponse.from_mongo(d).dict() for d in recent_all]
                })

            # 2. Dynamic Categories (Netflix-style shelves)
            for cat in all_categories:
                if not cat or cat == "General": continue 
                
                cat_filter = filter_query.copy()
                cat_filter["category"] = cat
                docs = list(collection.find(cat_filter).sort("ingested_at", -1).limit(100))
                if docs:
                    result_groups.append({
                        "title": titles.get(cat, f"{cat} Intelligence"),
                        "type": "row",
                        "templates": [TemplateResponse.from_mongo(d).dict() for d in docs]
                    })

            # 3. Add General / Miscellaneous at the bottom
            gen_filter = filter_query.copy()
            gen_filter["category"] = "General"
            general_docs = list(collection.find(gen_filter).sort("ingested_at", -1).limit(100))
            if general_docs:
                result_groups.append({
                    "title": "General Litigation Utility",
                    "type": "row",
                    "templates": [TemplateResponse.from_mongo(d).dict() for d in general_docs]
                })
            
    return {"groups": result_groups}


@router.get("/{template_id}")
async def get_template(template_id: str) -> TemplateResponse:
    collection = get_template_collection()
    try:
        doc = collection.find_one({"_id": ObjectId(template_id)})
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid ID")

    if not doc:
        raise HTTPException(status_code=404, detail="Not found")

    return TemplateResponse.from_mongo(doc, include_content=True)

@router.get("/{template_id}/download")
async def download_template(
    template_id: str,
    content: Optional[str] = Query(None, description="Customized content to download. If None, downloads base template.")
):
    """
    Generates and returns a .docx file for the template.
    """
    collection = get_template_collection()
    try:
        doc = collection.find_one({"_id": ObjectId(template_id)})
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid Template ID")

    if not doc:
        raise HTTPException(status_code=404, detail="Template not found")

    target_content = content or doc.get("content", "No content available.")
    title = doc.get("title", "Legal_Template")

    # Create DOCX in memory
    doc_obj = Document()
    doc_obj.add_heading(title, 0)
    
    # Process content by lines to preserve some structure
    for paragraph in target_content.split('\n'):
        if paragraph.strip():
            doc_obj.add_paragraph(paragraph)
        else:
            doc_obj.add_paragraph("") # Keep spacing

    file_stream = io.BytesIO()
    doc_obj.save(file_stream)
    file_stream.seek(0)

    filename = f"{title.replace(' ', '_')}.docx"
    return StreamingResponse(
        file_stream,
        media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        headers={"Content-Disposition": f"attachment; filename={filename}"}
    )

@router.post("/{template_id}/customize")
async def customize_template(
    template_id: str, 
    request: CustomizeRequest
):
    """
    Conversational AI Customization Engine.
    Handles multi-turn chat interaction to refine a legal draft.
    """
    collection = get_template_collection()
    try:
        doc = collection.find_one({"_id": ObjectId(template_id)})
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid Template ID")

    if not doc:
        raise HTTPException(status_code=404, detail="Template not found")

    base_content = doc.get("content", "")
    
    # Set up System Prompt
    system_msg = {
        "role": "system", 
        "content": f"""You are 'LETA', a high-end AI legal assistant specializing ONLY in GST litigation document drafting.
Your EXCLUSIVE goal is to help the user customize and refine this specific legal template:

---
{base_content}
---

STRICT OPERATIONAL RULES:
1. FOCUS: Your only purpose is to modify this draft. If the user asks general GST questions unrelated to drafting this document, politely decline and ask how you can help amend the current draft.
2. TONAL EXCELLENCE: Maintain a professional, respectful 'Sentinel' tone (high-end, precise, authoritative, and legally speaking).
3. FACT INTEGRATION: Incorporate user facts (names, dates, GSTINs, amounts, specific arguments) with 100% precision.
4. STATUTORY & REGULATORY COMPLETENESS: Ensure that the customized draft is highly elaborated and systematically mentions all possible sections, rules, notifications, and circulars that are applicable to strengthen the legal grounds. Do not truncate or use overly brief shortcuts.
5. NON-REPETITIVE: The draft and explanations must be elaborate yet completely non-repetitive, with high legal information density.
6. ITERATIVE IMPROVEMENT: If the user asks for changes ('make it more aggressive', 'add a paragraph about section 16(4)', 'shorten the prayer clause'), apply those changes to the latest version of the draft.
7. FORMATTING & CONCLUSION: Output the updated legal draft within clear markers: [DRAFT_START] and [DRAFT_END].
   - Inside the draft (before [DRAFT_END]), ensure there is a clear, definitive, and conclusive legal prayer/conclusion followed by a formal signature block.
   - Outside the markers (at the very end of your response), you MUST provide a structured, conclusive summary of the key changes, the legal rules/regulations applied, and actionable strategic recommendations.
8. COMPLETE RESPONSES: Ensure the draft is fully generated and reaches the signature block and conclusive summary. Never truncate or generate half-baked replies.
"""
    }

    # Build message history (provider-agnostic content)
    chat_messages = []
    if request.messages:
        for msg in request.messages:
            chat_messages.append({"role": msg.role, "content": msg.content})
    chat_messages.append({"role": "user", "content": request.user_context})

    try:
        client = get_ai_client()
        if LLM_PROVIDER == "anthropic":
            # Use Claude β€” system prompt goes in `system` param, not in messages
            resp = client.messages.create(
                model=CLAUDE_MAIN_MODEL,
                max_tokens=4096,
                system=system_msg["content"],
                messages=chat_messages,
                temperature=0.4,
            )
            full_response = resp.content[0].text
        else:
            # OpenAI / Ollama β€” system prompt is a message
            openai_messages = [system_msg] + chat_messages
            resp = client.chat.completions.create(
                model=LLM_MODEL,
                messages=openai_messages,
                temperature=0.4,
            )
            full_response = resp.choices[0].message.content

        # Extract draft if markers exist, otherwise take the whole thing
        draft = full_response
        if "[DRAFT_START]" in full_response and "[DRAFT_END]" in full_response:
            draft = full_response.split("[DRAFT_START]")[1].split("[DRAFT_END]")[0].strip()

        return {
            "status": "success",
            "full_response": full_response,
            "customized_draft": draft,
        }
    except Exception as e:
        logger.error(f"Template customization failed: {e}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"AI Customization failed: {str(e)}")

@router.post("/{template_id}/enhance")
async def enhance_template(
    template_id: str,
    request: EnhanceRequest
):
    """
    SOTA Professional AI Legal Draft Enhancement / Rewriting Engine.
    Rewrites the legal text professionally, improving grammar, statutory logic, and formal tone, while fully retaining all citations and details.
    """
    collection = get_template_collection()
    try:
        doc = collection.find_one({"_id": ObjectId(template_id)})
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid Template ID")

    if not doc:
        raise HTTPException(status_code=404, detail="Template not found")

    system_msg = {
        "role": "system",
        "content": """You are 'LETA', an elite enterprise legal drafting optimizer.
Your objective is to take the provided draft legal document and enhance/refine it to a supreme professional standard.

RULES:
1. REWRITE PROFESSIONALLY: Eliminate informal vocabulary, improve grammatical syntax, and maximize legal and statutory authority.
2. CITATION RETENTION: You must strictly preserve all legal sections, rule numbers, notifications, case citations, and specific facts (dates, names, prices) present in the text. Do not omit them under any circumstance.
3. LOGICAL RESTRUCTURING: Group the submissions into clear, formal, numbered sections (e.g., 1. Preliminary Objections, 2. Statement of Facts, 3. Grounds of Appeal / Substantive Defense, 4. Prayer).
4. OUTPUT ONLY THE ENHANCED CONTENT: Do not include conversational preambles, introductory lines, or post-scripts. Output ONLY the fully drafted, optimized document text directly. Do not surround it with markdown blocks. Just plain text.
"""
    }

    user_prompt = f"Optimize and professionally enhance the following legal draft:\n\n{request.current_content}"
    if request.instructions:
        user_prompt += f"\n\nFocus specifically on these instructions: {request.instructions}"

    chat_messages = [{"role": "user", "content": user_prompt}]

    try:
        client = get_ai_client()
        if LLM_PROVIDER == "anthropic":
            resp = client.messages.create(
                model=CLAUDE_MAIN_MODEL,
                max_tokens=4096,
                system=system_msg["content"],
                messages=chat_messages,
                temperature=0.3,
            )
            enhanced_draft = resp.content[0].text
        else:
            openai_messages = [system_msg] + chat_messages
            resp = client.chat.completions.create(
                model=LLM_MODEL,
                messages=openai_messages,
                temperature=0.3,
            )
            enhanced_draft = resp.choices[0].message.content

        return {
            "status": "success",
            "enhanced_content": enhanced_draft.strip()
        }
    except Exception as e:
        logger.error(f"Template enhancement failed: {e}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"AI Enhancement failed: {str(e)}")

@router.post("/upload")
async def upload_templates(
    files: List[UploadFile] = File(...),
    current_user: dict = Depends(get_current_user)
):
    # Dummy implementation for now, handled by manual ingestion scripts usually
    return {"message": "Admin upload logged. Processed via ingestion queue."}