File size: 17,822 Bytes
a8b213d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d5d779
a8b213d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d5d779
a8b213d
 
 
 
 
 
 
 
 
 
0d5d779
 
 
a8b213d
 
0d5d779
 
a8b213d
0d5d779
a8b213d
 
 
0d5d779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8b213d
 
 
 
 
0d5d779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8b213d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import re
import json
from typing import Dict, Any
import requests
import os
import numpy as np
import uuid
import datetime
import pathlib
import time

#TODO: allow to choose different provider later + dynamic routing when token expired
API_URL = "https://openrouter.ai/api/v1/chat/completions"
CEREBRAS_API_KEY = os.environ['OPENROUTER_KEY']

HEADERS = {"Authorization": f"Bearer {CEREBRAS_API_KEY}", "Content-Type": "application/json"}
JSON_OBJ_RE = re.compile(r"(\{[\s\S]*\})", re.MULTILINE)

INPUT_TOKEN_COUNT = np.array([], dtype=int)
OUTPUT_TOKEN_COUNT = np.array([], dtype=int)
TOTAL_TOKEN_COUNT = np.array([], dtype=int)
TOTAL_TOKEN_COUNT_EACH_GENERATION = np.array([])
TIME_INFOs = {}


FIDDLER_GUARDRAILS_TOKEN = os.environ['FIDDLER_TOKEN']
SAFETY_GUARDRAILS_URL = "https://guardrails.cloud.fiddler.ai/v3/guardrails/ftl-safety"
GUARDRAILS_HEADERS = {
    'Content-Type': 'application/json',
    'Authorization': f'Bearer {FIDDLER_GUARDRAILS_TOKEN}',
}

def get_safety_response(text, sleep_seconds: float = 0.5):
    time.sleep(sleep_seconds) # rate limited
    response = requests.post(
        SAFETY_GUARDRAILS_URL,
        headers=GUARDRAILS_HEADERS,
        json={'data': {'input': text}},
    )
    response.raise_for_status()
    response_dict = response.json()
    return response_dict

def text_safety_check(text: str, sleep_seconds: float = 0.5):
    confs = get_safety_response(text, sleep_seconds)
    max_conf = max(confs.values())
    max_category = list(confs.keys())[list(confs.values()).index(max_conf)]
    return max_conf, max_category

def _post_chat(messages: list, model: str, temperature: float = 0.2, timeout: int = 60) -> str:
    if model == 'openai/gpt-oss-120b': # OpenRouter's version of Cerebras-GPT
      payload = {"model": model, "messages": messages, "temperature": temperature, "provider": {"only": ["Cerebras", "together", "baseten", "deepinfra/fp4"]}}
    else: # default to Cerebras
      payload = {"model": model, "messages": messages, "temperature": temperature}

    resp = requests.post(API_URL, headers=HEADERS, json=payload, timeout=timeout)
    resp.raise_for_status()
    data = resp.json()

    # handle various shapes
    if "choices" in data and len(data["choices"]) > 0:
        # prefer message.content
        ch = data["choices"][0]
        if isinstance(ch, dict) and "message" in ch and "content" in ch["message"]:
            return ch["message"]["content"]
        if "text" in ch:
            return ch["text"]
    # final fallback
    raise RuntimeError("Unexpected HF response shape: " + json.dumps(data)[:200])

def _safe_extract_json(text: str) -> dict:
    # remove triple backticks
    text = re.sub(r"```(?:json)?\n?", "", text)
    m = JSON_OBJ_RE.search(text)
    if not m:
        raise ValueError("No JSON object found in model output.")
    js = m.group(1)
    # try load, fix trailing commas
    try:
        return json.loads(js)
    except json.JSONDecodeError:
        fixed = re.sub(r",\s*([}\]])", r"\1", js)
        return json.loads(fixed)


def structure_context_for_llm(
    source_text: str,
    model: str = "openai/gpt-oss-120b",
    temperature: float = 0.2,
    enable_fiddler = False,
) -> Dict[str, Any]:
    """
    Take a long source_text, split into N chunks, and restructure them
    so each chunk is self-contained, structured, and semantically meaningful.
    """

    system_message = {
        "role": "system",
        "content": (
            "Bạn là một trợ lý hữu ích chuyên xử lý và cấu trúc văn bản để phục vụ mô hình ngôn ngữ (LLM). Trả lời bằng Tiếng Việt\n"
            "Nhiệm vụ của bạn là:\n"
            "- Nếu văn bản dài trên 500 từ chia văn bản thành các chunk có ý nghĩa rõ ràng.\n"
            "- Mỗi chunk phải **tự chứa đủ thông tin** (self-contained) để LLM có thể hiểu độc lập.\n"
            "- Xác định **chủ đề chính (topic)** của mỗi chunk và dùng nó làm KEY trong JSON.\n"
            "- Trong mỗi topic, tổ chức thông tin thành cấu trúc rõ ràng gồm các trường:\n"
            "   - 'đoạn văn': nội dung gốc đã cấu trúc đầy đủ\n"
            "   - 'khái niệm chính': từ điểm chứa các khái niệm chính với khái niệm phụ hỗ trợ khái niệm chính đi kèm nếu có\n"
            "   - 'công thức': danh sách công thức (nếu có)\n"
            "   - 'ví dụ': ví dụ minh họa (nếu có)\n"
            "   - 'tóm tắt': tóm tắt nội dung, dễ hiểu\n"
            "- Giữ ngữ nghĩa liền mạch.\n"
            "- Chỉ TRẢ VỀ MỘT JSON hợp lệ theo schema, không kèm văn bản khác.\n\n"

            "Chỉ TRẢ VỀ duy nhất MỘT đối tượng JSON theo schema sau và không có bất kỳ văn bản nào khác:\n\n"
            "{\n"
            '  "Tên topic": {"đoạn văn": "nội dung đã cấu trúc của topic 1", "khái niệm chính": {"khái niệm chính 1":["khái niệm phụ", "..."],"khái niệm chính 2":["khái niệm phụ", "..."]}, "công thức": ["..."], "ví dụ": ["..."], "tóm tắt": "tóm tắt ngắn gọn"},\n'
            "}\n"
        )
    }

    user_message = {
        "role": "user",
        "content": (
            "Hãy chia văn bản sau thành nhiều chunk theo hướng dẫn trên và xuất JSON hợp lệ.\n"
            f"### Văn bản nguồn:\n{source_text}"
        )
    }

    if enable_fiddler:
        max_conf, max_cat = text_safety_check(user_message['content'])
        if max_conf > 0.5:
            print(f"Harmful content detected: ({max_cat} : {max_conf})")
            return {}

    raw = _post_chat([system_message, user_message], model=model, temperature=temperature)
    parsed = _safe_extract_json(raw)
    if not isinstance(parsed, dict):
        raise ValueError(f"Generator returned invalid structure. Raw:\n{raw}")
    return parsed


def new_generate_mcqs_from_text(
    source_text: Dict,
    n: int = 3,
    model: str = "openai/openai/gpt-oss-120b",
    temperature: float = 0.2,
    enable_fiddler = False,
    target_difficulty: str = "easy",

) -> Dict[str, Any]:


    expected_concepts = {
        "easy": 1,
        "medium": 2,
        "hard": (3, 4)
    }
    if isinstance(expected_concepts[target_difficulty], tuple):
        min_concepts, max_concepts = expected_concepts[target_difficulty]
        concept_range = f"{min_concepts}-{max_concepts}"
    else:
        concept_range = expected_concepts[target_difficulty]


    difficulty_prompts = {
        "easy": (
            "- Câu hỏi DỄ: kiểm tra duy nhất 1 khái niệm chính cơ bản dễ hiểu, định nghĩa, hoặc công thức đơn giản."
            "- Đáp án có thể tìm thấy trực tiếp trong văn bản."
            "- Ngữ cảnh đủ để hiểu khái niệm chính."
            "- Distractors khác biệt rõ ràng, dễ loại bỏ."
            "- Độ dài câu hỏi ngắn gọn không quá 10-20 từ hoặc ít hơn 120 ký tự, tập trung vào một ý duy nhất.\n"
        ),
        "medium": (
            "- Câu hỏi TRUNG BÌNH kiểm tra khái niệm chính trong văn bản"
            "- Nếu câu hỏi thuộc dạng áp dụng và suy luận thiếu dữ liệu để trả lời câu hỏi, thêm nội dung hoặc ví dụ từ văn bản nguồn."
            "- Các Distractors không quá giống nhau."
            "- Độ dài câu hỏi vừa phải khoảng 23–30 từ hoặc khoảng 150 - 180 ký tự, có thêm chi tiết phụ để suy luận.\n"
        ),
        "hard": (
            "- Câu hỏi KHÓ kiểm tra thông tin được phân tích/tổng hợp"
            "- Nếu câu hỏi thuộc dạng áp dụng và suy luận thiếu dữ liệu để trả lời câu hỏi, thêm nội dung hoặc ví dụ từ văn bản nguồn."
            "- Ít nhất 2 distractors gần giống đáp án đúng, độ tương đồng cao. "
            f"- Đáp án yêu cầu học sinh suy luận hoặc áp dụng công thức vào ví dụ nếu có."
            "- Độ dài câu hỏi dài hơn 35 từ hoặc hơn 200 ký tự.\n \n"
        )
    }

    difficult_criteria = difficulty_prompts[target_difficulty] # "easy", "medium", "hard"
    print(concept_range)
    system_message = {
        "role": "system",
        "content": (
            "Bạn là một trợ lý hữu ích chuyên tạo câu hỏi trắc nghiệm (MCQ). Luôn trả lời bằng tiếng việt"
            f"Đảm bảo chỉ tạo sinh câu trắc nghiệm có độ khó sau {difficult_criteria}"
            f"Quan trọng: Mỗi câu hỏi chỉ sử dụng chính xác {concept_range} khái niệm chính (mỗi khái niệm chính có 1 danh sách khái niệm phụ) từ văn bản nguồn. "
            "Mỗi câu hỏi và đáp án phải dựa trên thông tin từ văn bản nguồn. Không được đưa kiến thức ngoài vào."
            "Chỉ TRẢ VỀ duy nhất một đối tượng JSON theo đúng schema sau và không kèm giải thích hay trường thêm:\n\n"
            "{\n"
            '  "1": { "câu hỏi": "...", "lựa chọn": {"a":"...","b":"...","c":"...","d":"..."}, "đáp án":"...", "khái niệm sử dụng": {"khái niệm chính":["khái niệm phụ", "..."], "..."]}},\n'
            '  "2": { ... }\n'
            "}\n\n"
            "Lưu ý:\n"
            f"- Tạo đúng {n} mục, đánh số từ 1 tới {n}.\n"
            "- Khóa 'lựa chọn' phải có các phím a, b, c, d.\n"
            "- 'đáp án' phải là toàn văn đáp án đúng (không phải ký tự chữ cái), và giá trị này phải khớp chính xác với một trong các giá trị trong 'options'.\n"
            "- Toàn bộ thông tin cần thiết để trả lời phải nằm trong chính câu hỏi, không tham chiếu lại văn bản nguồn."
            f"- Sử dụng chính xác {concept_range} khái niệm chính"
        )
    }

    user_message = {
        "role": "user",
        "content": (
            f"Hãy tạo {n} câu hỏi trắc nghiệm từ nội dung dưới đây. Chỉ sử dụng nội dung này làm nguồn duy nhất để xây dựng câu hỏi.\n\n"

            "### Yêu cầu:\n"
            "- Bám sát vào thông tin trong văn bản; không thêm kiến thức ngoài.\n"
            "- Nếu văn bản thiếu chi tiết, hãy tạo phương án nhiễu (distractors) hợp lý, nhưng phải có thể biện minh từ nội dung hoặc ngữ cảnh.\n"
            f"### Văn bản nguồn:\n{source_text}"
        )
    }


    if enable_fiddler:
        max_conf, max_cat = text_safety_check(user_message['content'])
        if max_conf > 0.5:
            print(f"Harmful content detected: ({max_cat} : {max_conf})")
            return {}

    raw = _post_chat([system_message, user_message], model=model, temperature=temperature)
    # print('\n\n',raw)
    parsed = _safe_extract_json(raw)
    # basic validation
    if not isinstance(parsed, dict) or len(parsed) != n:
        raise ValueError(f"Generator returned invalid structure. Raw:\n{raw}")
    return parsed



def generate_mcqs_from_text(
    source_text: str,
    n: int = 3,
    model: str = "openai/gpt-oss-120b",
    temperature: float = 0.2,
    enable_fiddler: bool = False,
) -> Dict[str, Any]:
    system_message = {
        "role": "system",
        "content": (
            "Bạn là một trợ lý hữu ích chuyên tạo câu hỏi trắc nghiệm. "
            "Chỉ TRẢ VỀ duy nhất một đối tượng JSON theo đúng schema sau và không có bất kỳ văn bản nào khác:\n\n"
            "{\n"
            '  "1": { "câu hỏi": "...", "lựa chọn": {"a":"...","b":"...","c":"...","d":"..."}, "đáp án":"..."},\n'
            '  "2": { ... }\n'
            "}\n\n"
            "Lưu ý:\n"
            f"- Tạo đúng {n} mục, đánh số từ 1 tới {n}.\n"
            "- Khóa 'lựa chọn' phải có các phím a, b, c, d.\n"
            "- 'đáp án' phải là toàn văn đáp án đúng (không phải ký tự chữ cái), và giá trị này phải khớp chính xác với một trong các giá trị trong 'lựa chọn'.\n"
            "- Không kèm giải thích hay trường thêm.\n"
            "- Các phương án sai (distractors) phải hợp lý và không lặp lại."
        )
    }
    user_message = {
        "role": "user",
        "content": (
            f"Hãy tạo {n} câu hỏi trắc nghiệm từ nội dung dưới đây. Dùng nội dung này làm nguồn duy nhất để trả lời."
            "Nếu nội dung quá ít để tạo câu hỏi chính xác, hãy tạo các phương án hợp lý nhưng có thể biện minh được.\n\n"
            f"Nội dung:\n\n{source_text}"
        )
    }

    if enable_fiddler:
        max_conf, max_cat = text_safety_check(user_message['content'])
        if max_conf > 0.5:
            print(f"Harmful content detected: ({max_cat} : {max_conf})")
            return {"error": "Harmful content detected", f"{max_cat}": f"{str(max_conf)}"}

    raw = _post_chat([system_message, user_message], model=model, temperature=temperature)
    parsed = _safe_extract_json(raw)

    # validate structure and length
    if not isinstance(parsed, dict) or len(parsed) != n:
        raise ValueError(f"Generator returned invalid structure. Raw:\n{raw}")
    return parsed


# helpers to read/reset token counts
def get_token_count_record():
    global TOTAL_TOKEN_COUNT_EACH_GENERATION
    TOTAL_TOKEN_COUNT_EACH_GENERATION = np.append(TOTAL_TOKEN_COUNT_EACH_GENERATION, np.sum(TOTAL_TOKEN_COUNT))

    token_record = {
        'INPUT_token_count': np.sum(INPUT_TOKEN_COUNT),
        'OUTPUT_token_count': np.sum(OUTPUT_TOKEN_COUNT),
        'AVG_INPUT_token_count': np.average(INPUT_TOKEN_COUNT),
        'AVG_OUTPUT_token_count': np.average(OUTPUT_TOKEN_COUNT),
        'TOTAL_token_count': TOTAL_TOKEN_COUNT,
        'TOTAL_token_count_PER_GENERATION - ': TOTAL_TOKEN_COUNT_EACH_GENERATION,
        'AVG_TOTAL_token_count_PER_GENERATION': [np.average(TOTAL_TOKEN_COUNT_EACH_GENERATION), len(TOTAL_TOKEN_COUNT_EACH_GENERATION)],
    }

    return token_record


def reset_token_count(reset_all=None):
    """Call in app.py. For Reset Token Count after 1 Generation Session"""
    global INPUT_TOKEN_COUNT, OUTPUT_TOKEN_COUNT, TOTAL_TOKEN_COUNT, TOTAL_TOKEN_COUNT_EACH_GENERATION

    INPUT_TOKEN_COUNT = np.array([])
    OUTPUT_TOKEN_COUNT = np.array([])
    TOTAL_TOKEN_COUNT = np.array([])

    if reset_all:
        TOTAL_TOKEN_COUNT_EACH_GENERATION = np.array([])


def update_token_count(token_usage):
    """Update Token Count for each generation
    "usage": {
        "prompt_tokens": 1209,
        "completion_tokens": 313,
        "total_tokens": 1522,
        "prompt_tokens_details": {
        "cached_tokens": 0
    }
    """
    global INPUT_TOKEN_COUNT, OUTPUT_TOKEN_COUNT, TOTAL_TOKEN_COUNT # get value from global
    prompt_tokens = token_usage['prompt_tokens'] # INPUT token
    completion_tokens = token_usage['completion_tokens'] # OUTPUT token
    total_tokens = token_usage['total_tokens'] # TOTAL token

    INPUT_TOKEN_COUNT = np.append(INPUT_TOKEN_COUNT, prompt_tokens)
    OUTPUT_TOKEN_COUNT = np.append(OUTPUT_TOKEN_COUNT, completion_tokens)
    TOTAL_TOKEN_COUNT = np.append(TOTAL_TOKEN_COUNT, total_tokens)

    # print("Input Token Increase:", INPUT_TOKEN_COUNT)
    # print("Output Token Increase:", OUTPUT_TOKEN_COUNT)


def save_logs(record: dict, log_path:str = "logs/generation_log.jsonl"):
    """
    Append log to log_path
    record: dict with keys you want to store (e.g. filename, input/output token_count, collection, etc..)
    """
    # create file if not exist
    p = pathlib.Path(log_path)
    p.parent.mkdir(parents=True, exist_ok=True)

    # add id/timestampt if missing
    record.setdefault('id', str(uuid.uuid4()))
    record.setdefault('timestamp_utc', datetime.datetime.now(datetime.timezone.utc).isoformat() + "Z") # get current time at timezone

    # append as 1 json file for each generation
    with open(p, "a", encoding='utf-8') as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")


def update_time_info(time_info):
    """
    "time_info": {
        "queue_time": 0.000600429,
        "prompt_time": 0.052739054,
        "completion_time": 0.15692187,
        "total_time": 0.2117476463317871,
        "created": 1755599458
    }
    """
    time_info['created'] = time_info
    time_info['created'].pop('created')


def get_time_info():
    global TIME_INFOs
    return TIME_INFOs
    # token_record = {
    #     'completion_time': np.sum(INPUT_TOKEN_COUNT),
    #     'total_time': np.sum(OUTPUT_TOKEN_COUNT),
    # }

def log_pipeline(path, content):
    print("Save result to test/mcq_output.json")
    #save_to_local(path=path, content=content)
    token_record = get_token_count_record()

    print("Token Record:")
    for record, value in token_record.items():
        print(f'{record}:{value}', '\n')

    reset_token_count()

def save_to_local(path, content):
    """
        path = 'test/raw_data.json'
        path = 'test/mcq_output.json'
        path = 'test/extract_output.md'

    """
    p = pathlib.Path(path)
    p.parent.mkdir(parents=True, exist_ok=True) # create folder if missing
    p.touch(exist_ok=True) # create file if missing

    if path.lower().endswith('.json'):
        with open(path, 'w', encoding='utf-8') as f:
            f.write(json.dumps(content, ensure_ascii=False, indent=2))
    else:
        with open(path, 'w', encoding='utf-8') as f:
            f.write(f'{content}') # md, txt