File size: 5,746 Bytes
7346617
c1272c5
a3037d6
7346617
a3037d6
 
 
 
7346617
a3037d6
 
 
0070274
 
 
 
 
 
 
a3037d6
 
 
 
 
 
7346617
 
 
 
 
 
 
 
 
 
 
 
 
746d35c
7346617
 
 
 
 
 
 
 
 
 
 
 
 
 
a3037d6
7346617
 
a3037d6
 
7346617
 
 
9917e61
7346617
9917e61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7346617
9917e61
 
 
 
 
 
 
 
a3037d6
0070274
7346617
0070274
 
 
 
 
 
 
a3037d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""SLM generation with transformers — ZeroGPU-compatible (fully lazy)."""

from typing import List, Dict

from config import LLM_MODEL_ID, LLM_MAX_NEW_TOKENS, LLM_TEMPERATURE, LLM_DO_SAMPLE


class AethronGenerator:
    """Portfolio response generator — model loads lazily on first generate() call."""

    SYSTEM_PROMPT = """You are Aethron, the portfolio intelligence agent for Arash Nicoomanesh. You answer questions about Arash's experience, skills, projects, and architecture philosophy based STRICTLY on the provided context.

CRITICAL RULES:
1. STRICT factual GROUNDING: Answer using ONLY information explicitly written in the provided Context chunks. Do NOT invent, extrapolate, or infer dates, tenure lengths, years of experience, or company names that are not explicitly stated in the context. If a specific number or date is not in the context, do not guess — say what IS known and stop.
2. NO unsolicited contact info: Do NOT append email addresses or contact details (e.g., anicomanesh@gmail.com) unless the user explicitly asks how to contact, hire, or reach Arash. Focus strictly on answering the query.
3. CONCISE & COMPLETE: Provide clear, complete, and scannable answers using Markdown bullet points where appropriate. Finish your thoughts completely without truncation.
4. UNCERTAINTY: If the context does not contain enough information to answer fully, state clearly what is known from the context and stop. Do not pad with invented details.
5. For technical questions, reference specific projects, repositories, or architecture layers when relevant.
6. For recruiter questions, be concise and highlight technologies and roles explicitly mentioned in the context."""

    RECRUITER_PROMPT = "You are speaking with a recruiter or hiring manager. Emphasize: years of experience, specific technologies, remote work availability, and education. Be professional and concise."

    TECHNICAL_PROMPT = "You are speaking with a technical partner or peer. Emphasize: architecture decisions, implementation details, repository names, and design philosophy. Be precise and technically deep."

    def __init__(self):
        self.pipe = None
        self.tokenizer = None

    def _load_model(self):
        """Load model and pipeline — called lazily inside @spaces.GPU context."""
        if self.pipe is not None:
            return

        import torch
        from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline

        print(f"Loading SLM: {LLM_MODEL_ID}...")

        self.tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_ID)

        model = AutoModelForCausalLM.from_pretrained(
            LLM_MODEL_ID, dtype=torch.float16, device_map="auto"
        )

        self.pipe = pipeline(
            "text-generation",
            model=model,
            tokenizer=self.tokenizer,
            return_full_text=False,
            do_sample=LLM_DO_SAMPLE,
            temperature=LLM_TEMPERATURE if LLM_DO_SAMPLE else None,
            max_new_tokens=LLM_MAX_NEW_TOKENS,
            pad_token_id=self.tokenizer.eos_token_id,
        )

        print("SLM loaded successfully")

    def generate(self, query: str, chunks: List[Dict], persona: str = "general") -> Dict:
        """Generate response from retrieved context."""
        self._load_model()

        context_text = "\n\n".join([
            f"[Source: {c['section_type']} - {c['title']}]\n{c['text']}"
            for c in chunks
        ])

        persona_addon = ""
        if persona == "recruiter":
            persona_addon = self.RECRUITER_PROMPT
        elif persona == "technical":
            persona_addon = self.TECHNICAL_PROMPT

        system_content = self.SYSTEM_PROMPT
        if persona_addon:
            system_content += "\n\n" + persona_addon

        user_content = f"Context:\n{context_text}\n\nQuestion: {query}\n\nAnswer based strictly on the above context:"

        messages = [
            {"role": "system", "content": system_content},
            {"role": "user", "content": user_content}
        ]

        prompt = self.tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )

        outputs = self.pipe(prompt)
        response_text = outputs[0]["generated_text"].strip()

        response_text = self._clean_response(response_text)
        guard_passed = self._guard_check(response_text, chunks)

        unique_sources = self._deduplicate_sources(chunks)

        return {
            "answer": response_text,
            "sources": unique_sources,
            "persona": persona,
            "guard_passed": guard_passed,
        }

    def _deduplicate_sources(self, chunks: List[Dict]) -> List[str]:
        """Extract and deduplicate chunk source titles, preserving retrieval order."""
        raw_titles = []
        for c in chunks:
            title = c.get("title") or c.get("section_type") or c.get("source")
            if title:
                raw_titles.append(str(title).strip())
        return list(dict.fromkeys(raw_titles))

    def _clean_response(self, text: str) -> str:
        stops = ["<|end|>", "<|user|>", "<|system|>", "<|assistant|>", "Question:", "Context:"]
        for stop in stops:
            if stop in text:
                text = text[:text.index(stop)].strip()
        return text

    def _guard_check(self, response: str, chunks: List[Dict]) -> bool:
        refusal_phrases = ["don't have", "not in the portfolio", "cannot find"]
        if any(p in response.lower() for p in refusal_phrases):
            return True
        self_references = ["i am a", "i have", "i work", "my experience", "my skills"]
        if any(p in response.lower() for p in self_references):
            return False
        return True