File size: 5,555 Bytes
0921a5d
 
 
 
 
 
 
 
 
 
 
46e02e0
0921a5d
 
 
 
46e02e0
0921a5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

from pathlib import Path
import re
from typing import Any

from env.config import SUPPORTED_IMAGE_SUFFIXES

# Match headings that the tutoring model is instructed to emit.
_SECTION_MARKER_PATTERN = re.compile(
    r"(?im)^[ \t]*(?:[-*][ \t]*)?(?:#{1,6}[ \t]*)?"
    r"(?:\*\*)?(?:={2,}[ \t]*)*"
    r"(?P<label>"
    r"problem read|knowns?|strategy|worked steps?|check|next hint|parent note"
    r")"
    r"\b"
    r"(?:[ \t]*={2,})*(?:\*\*)?[ \t]*(?::|-)?[ \t]*"
    r"(?P<trailing>[^\n]*)$"
)

_SECTION_ORDER = (
    "problem",
    "knowns",
    "strategy",
    "steps",
    "check",
    "hint",
    "parent",
)

_SECTION_DEFAULTS = {
    "problem": "Upload a homework image or type the question to begin.",
    "knowns": "- No givens identified yet.",
    "strategy": "No strategy generated yet.",
    "steps": "No worked steps generated yet.",
    "check": "No answer check generated yet.",
    "hint": "Ask for a hint after the first explanation.",
    "parent": "Parent support note will appear here.",
}


def resolve_path(file_input: object | None) -> Path | None:
    """Normalizes Gradio file payload variants into a local path."""
    # Empty upload components should let text or audio drive the request.
    if not file_input:
        return None
    if isinstance(file_input, (list, tuple)):
        for item in file_input:
            path = resolve_path(item)
            if path:
                return path
        return None
    if isinstance(file_input, dict):
        for key in ("path", "name", "orig_name"):
            value = file_input.get(key)
            if value:
                return Path(str(value))
        return None
    return Path(str(file_input))


def validate_image_path(file_input: object | None) -> tuple[str | None, str]:
    """Returns a usable image path and a short validation message."""
    # Accept common classroom photo formats only.
    path = resolve_path(file_input)
    if not path:
        return None, "No image uploaded."
    suffix = path.suffix.lower()
    if suffix not in SUPPORTED_IMAGE_SUFFIXES:
        return None, f"Unsupported image format: {suffix}."
    if not path.exists():
        return None, "Uploaded image was not found in the local runtime."
    return str(path), f"Image accepted: {path.name}"


def stringify_content(content: Any) -> str:
    """Converts Gradio text, audio transcripts, and message payloads into prompt-safe text."""
    # Plain textbox messages arrive as strings.
    if content is None:
        return ""
    if isinstance(content, str):
        return content.strip()
    if isinstance(content, (list, tuple)):
        parts = [stringify_content(item) for item in content]
        return " ".join(part for part in parts if part).strip()
    if isinstance(content, dict):
        for key in ("text", "value", "path", "url", "name", "alt_text"):
            value = content.get(key)
            if value:
                return stringify_content(value)
        return ""
    return str(content).strip()


def _canonical_section(label: str) -> str:
    """Maps model heading variants onto the app's fixed output cards."""
    normalized = re.sub(r"[^a-z]+", " ", label.lower()).strip()
    if "problem" in normalized:
        return "problem"
    if "known" in normalized:
        return "knowns"
    if "strategy" in normalized:
        return "strategy"
    if "worked" in normalized or "step" in normalized:
        return "steps"
    if "check" in normalized:
        return "check"
    if "hint" in normalized:
        return "hint"
    return "parent"


def parse_sections(response: str) -> tuple[str, str, str, str, str, str, str]:
    """Extracts tutoring sections from a structured model response."""
    # Find candidate headings and keep defaults when a section is absent.
    matches = list(_SECTION_MARKER_PATTERN.finditer(response))
    sections = dict(_SECTION_DEFAULTS)
    if not matches:
        return (
            sections["problem"],
            sections["knowns"],
            sections["strategy"],
            sections["steps"],
            sections["check"],
            sections["hint"],
            sections["parent"],
        )

    # Prefer the best ordered block of sections in the generation.
    best_values: dict[str, str] = {}
    best_count = -1
    for start_index, _ in enumerate(matches):
        values: dict[str, str] = {}
        last_order_index = -1
        for current_index in range(start_index, len(matches)):
            current = matches[current_index]
            section = _canonical_section(current.group("label"))
            order_index = _SECTION_ORDER.index(section)
            if order_index <= last_order_index:
                break
            next_start = (
                matches[current_index + 1].start()
                if current_index + 1 < len(matches)
                else len(response)
            )
            value = "\n".join(
                [current.group("trailing"), response[current.end() : next_start]]
            ).strip()
            if value:
                values[section] = value
            last_order_index = order_index
        if len(values) >= best_count:
            best_values = values
            best_count = len(values)

    # Merge extracted values over stable UI defaults.
    sections.update(best_values)
    return (
        sections["problem"],
        sections["knowns"],
        sections["strategy"],
        sections["steps"],
        sections["check"],
        sections["hint"],
        sections["parent"],
    )