File size: 17,784 Bytes
3fac7d8
 
 
 
 
 
 
 
119ae02
3fac7d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119ae02
3fac7d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119ae02
3fac7d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119ae02
3fac7d8
119ae02
3fac7d8
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
"""Gradio MCP server for Common Core Standards search and lookup."""

import os
import json

from dotenv import load_dotenv
import gradio as gr
from huggingface_hub import InferenceClient
from loguru import logger

# Load environment variables from .env file
load_dotenv()

from src.search import find_relevant_standards_impl
from src.lookup import get_standard_details_impl

# Initialize the Hugging Face Inference Client
# Use HF_TOKEN from environment (automatically available in Hugging Face Spaces)
# Provider is required for models that need Inference Providers (e.g., Together AI, Nebius)
HF_TOKEN = os.environ.get("HF_TOKEN")
client = InferenceClient(
    provider="together",  # Required: specifies the inference provider for tool calling
    token=HF_TOKEN
)

# Define the function schemas in OpenAI format for the model
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "find_relevant_standards",
            "description": "Searches for educational standards relevant to a learning activity using semantic search. Use this when the user asks about standards for a specific activity, lesson, or educational objective.",
            "parameters": {
                "type": "object",
                "properties": {
                    "activity": {
                        "type": "string",
                        "description": "A natural language description of the learning activity, lesson, or educational objective. Be specific and descriptive."
                    },
                    "max_results": {
                        "type": "integer",
                        "description": "Maximum number of standards to return (1-20). Default is 5.",
                        "default": 5,
                        "minimum": 1,
                        "maximum": 20
                    },
                    "grade": {
                        "type": "string",
                        "description": "Optional grade level filter. Valid values: K, 01, 02, 03, 04, 05, 06, 07, 08, 09, 10, 11, 12, or 09-12 for high school range.",
                        "enum": ["K", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "09-12"]
                    }
                },
                "required": ["activity"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_standard_details",
            "description": "Retrieves complete metadata and content for a specific educational standard by its GUID (_id field). Use this when you have the exact GUID from a previous search result. This function ONLY accepts GUIDs, not statement notations or other identifiers. For searching by content or notation, use find_relevant_standards instead.",
            "parameters": {
                "type": "object",
                "properties": {
                    "standard_id": {
                        "type": "string",
                        "description": "The standard's GUID (_id field) - must be a valid GUID format (e.g., 'EA60C8D165F6481B90BFF782CE193F93'). This function does NOT accept statement notations or other identifier formats."
                    }
                },
                "required": ["standard_id"]
            }
        }
    }
]

def find_relevant_standards(
    activity: str,
    max_results: int = 5,
    grade: str | None = None,
) -> str:
    """
    Searches for educational standards relevant to a learning activity using semantic search.

    This function performs a vector similarity search over the Common Core Standards database
    to find standards that match the described learning activity. Results are ranked by relevance
    and can be filtered by grade level.

    Args:
        activity: A natural language description of the learning activity, lesson, or educational
            objective. Examples: "teaching fractions to third graders", "reading comprehension
            activities", "solving quadratic equations". This is the primary search query and should
            be descriptive and specific for best results.

        max_results: The maximum number of standards to return. Must be between 1 and 20.
            Default is 5. Higher values return more results but may include less relevant matches.

        grade: Optional grade level filter. Must be one of the following valid grade level codes:
            - "K" for Kindergarten
            - "01" for Grade 1
            - "02" for Grade 2
            - "03" for Grade 3
            - "04" for Grade 4
            - "05" for Grade 5
            - "06" for Grade 6
            - "07" for Grade 7
            - "08" for Grade 8
            - "09" for Grade 9
            - "10" for Grade 10
            - "11" for Grade 11
            - "12" for Grade 12
            - "09-12" for high school range (when standards span multiple high school grades)

            If None or empty string, no grade filtering is applied and standards from all grade
            levels may be returned. The grade filter uses exact matching against the education_levels
            metadata field in the database.

    Returns:
        A JSON string containing a structured response with the following format:
        {
            "success": true|false,
            "results": [
                {
                    "_id": "standard_guid",
                    "content": "full standard text with hierarchy",
                    "subject": "Mathematics",
                    "education_levels": ["03"],
                    "statement_notation": "3.NF.A.1",
                    "standard_set_title": "Grade 3",
                    "score": 0.85
                },
                ...
            ],
            "message": "Found N matching standards" or error message,
            "error_type": null or error type if success is false
        }

        On success, the results array contains up to max_results standards, sorted by relevance
        score (highest first). Each result includes the full standard content, metadata, and
        relevance score. On error, success is false and an error message describes the issue.
    """
    logger.info(f"Finding relevant standards for activity: {activity}")
    # Handle empty string from dropdown (convert to None)
    if grade == "":
        grade = None

    # Ensure max_results is an integer (gr.Number returns float by default)
    max_results = int(max_results)

    return find_relevant_standards_impl(activity, max_results, grade)


def get_standard_details(standard_id: str) -> str:
    """
    Retrieves complete metadata and content for a specific educational standard by its GUID.

    This function performs a direct lookup using the standard's GUID (_id field) only.
    It does NOT accept statement notations, ASN identifiers, or any other identifier formats.
    Use find_relevant_standards to search for standards by content or metadata.

    Args:
        standard_id: The standard's GUID (_id field) - must be a valid GUID format
            (e.g., "EA60C8D165F6481B90BFF782CE193F93"). This is the GUID returned in
            search results from find_relevant_standards.

    Returns:
        A JSON string containing a structured response with the following format:
        {
            "success": true|false,
            "results": [
                {
                    "_id": "standard_guid",
                    "content": "full standard text with hierarchy",
                    "subject": "Mathematics",
                    "education_levels": ["03"],
                    "statement_notation": "3.NF.A.1",
                    "standard_set_title": "Grade 3",
                    "asn_identifier": "S21238682",
                    "depth": 3,
                    "is_leaf": true,
                    "parent_id": "parent_guid",
                    "ancestor_ids": [...],
                    "child_ids": [...],
                    ... (all available metadata fields)
                }
            ],
            "message": "Retrieved standard details" or error message,
            "error_type": null or error type if success is false
        }

        On success, the results array contains exactly one standard object with all available
        metadata fields including hierarchy relationships, content, and identifiers. On error
        (e.g., standard not found), success is false and the message provides guidance, such as
        suggesting to use find_relevant_standards for searching.

    Raises:
        This function does not raise exceptions. All errors are returned as JSON responses
        with success=false and appropriate error messages.
    """
    logger.info(f"Getting standard details for standard ID: {standard_id}")
    return get_standard_details_impl(standard_id)


def chat_with_standards(message: str, history: list):
    """
    Chat function that uses MCP tools via Hugging Face Inference API with tool calling.

    This function integrates with Qwen2.5-7B-Instruct to answer questions about educational
    standards. The model can call find_relevant_standards and get_standard_details tools
    to retrieve information and provide accurate responses.

    Args:
        message: The user's current message/query
        history: Chat history in Gradio 6 messages format. Each message is a dict with
            "role" and "content" keys. In Gradio 6, content uses structured format:
            [{"type": "text", "text": "..."}, ...] for text content.

    Returns:
        Structured content as a list of content blocks. When tool calls are made, includes:
        - Expandable JSON blocks showing tool call results
        - The final assistant response as text
        When no tool calls are made, returns a simple text response.
    """
    # Convert Gradio 6 history format to OpenAI messages format
    # Gradio 6 uses structured content: {"role": "user", "content": [{"type": "text", "text": "..."}]}
    messages = []
    if history:
        for msg in history:
            if isinstance(msg, dict):
                role = msg.get("role", "user")
                content = msg.get("content", "")

                # Handle Gradio 6 structured content format
                if isinstance(content, list):
                    # Extract text from content blocks
                    text_parts = []
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "text":
                            text_parts.append(block.get("text", ""))
                    content = " ".join(text_parts)

                messages.append({
                    "role": role,
                    "content": content
                })

    # Add system message to guide the model
    system_message = {
        "role": "system",
        "content": "You are a helpful assistant for parents and teachers. Your role is to help them plan educational activities and find educational requirements for activities they might have already done. You have access to tools that can search for standards and retrieve standard details. Use these tools when users ask about standards, learning activities, or educational requirements. Always provide clear, helpful responses based on the tool results."
    }

    # Add current user message
    messages.append({"role": "user", "content": message})

    # Prepare full message list with system message
    full_messages = [system_message] + messages

    try:
        # Initial API call with tools
        response = client.chat.completions.create(
            model="Qwen/Qwen2.5-7B-Instruct",
            messages=full_messages,
            tools=TOOLS,
            tool_choice="auto",  # Let the model decide when to call functions
            temperature=0.7,
            max_tokens=1000,
        )

        response_message = response.choices[0].message

        # Check if model wants to call functions
        if response_message.tool_calls:
            # Add assistant's tool call request to messages
            full_messages.append(response_message)

            # Store tool call results for display
            tool_results = []

            # Process each tool call
            for tool_call in response_message.tool_calls:
                function_name = tool_call.function.name
                function_args = json.loads(tool_call.function.arguments)

                # Execute the function
                if function_name == "find_relevant_standards":
                    result = find_relevant_standards_impl(
                        activity=function_args.get("activity", ""),
                        max_results=function_args.get("max_results", 5),
                        grade=function_args.get("grade"),
                    )
                elif function_name == "get_standard_details":
                    result = get_standard_details_impl(
                        standard_id=function_args.get("standard_id", "")
                    )
                else:
                    result = json.dumps({"error": f"Function {function_name} not available"})

                # Parse result JSON for display
                try:
                    result_data = json.loads(result) if isinstance(result, str) else result
                except json.JSONDecodeError:
                    result_data = {"raw_result": result}

                # Store tool call info for display
                tool_results.append({
                    "function": function_name,
                    "arguments": function_args,
                    "result": result_data
                })

                # Add function result to messages
                full_messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "name": function_name,
                    "content": result,
                })

            # Get final response with function results
            final_response = client.chat.completions.create(
                model="Qwen/Qwen2.5-7B-Instruct",
                messages=full_messages,
                temperature=0.7,
                max_tokens=1000,
            )

            # Build structured response with tool call results and final answer
            response_blocks = []
            
            # Add tool call results as expandable JSON blocks using markdown
            for i, tool_result in enumerate(tool_results):
                # Format arguments and result as pretty JSON
                args_json = json.dumps(tool_result["arguments"], indent=2)
                result_json = json.dumps(tool_result["result"], indent=2)
                
                # Create collapsible markdown section
                tool_markdown = f"""<details>
<summary><strong>🔧 Tool Call: {tool_result["function"]}</strong></summary>

**Arguments:**
```json
{args_json}
```

**Result:**
```json
{result_json}
```
</details>
"""
                response_blocks.append({
                    "type": "text",
                    "text": tool_markdown
                })
            
            # Add separator before final response
            response_blocks.append({
                "type": "text",
                "text": "---\n"
            })
            
            # Add final assistant response as text
            response_blocks.append({
                "type": "text",
                "text": final_response.choices[0].message.content
            })

            return response_blocks
        else:
            # No tool calls, return direct response as text
            return response_message.content

    except Exception as e:
        # Error handling
        return f"I apologize, but I encountered an error: {str(e)}. Please try again or rephrase your question."


# Create Gradio interface
demo = gr.TabbedInterface(
    [
        gr.ChatInterface(
            fn=chat_with_standards,  # See complete implementation above
            title="Chat with Standards",
            description="Ask questions about educational standards. The AI will use MCP tools to find relevant information.",
            examples=["What standards apply to teaching fractions in 3rd grade?", "Find standards for reading comprehension"],
            api_visibility="private",  # Hide from MCP server - only expose search and lookup tools
        ),
        gr.Interface(
            fn=find_relevant_standards,
            inputs=[
                gr.Textbox(label="Activity Description", placeholder="Describe a learning activity..."),
                gr.Number(label="Max Results", value=5, minimum=1, maximum=20),
                gr.Dropdown(
                    label="Grade (optional)",
                    choices=["", "K", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "09-12"],
                    value=None,
                    info="Select a grade level to filter results"
                ),
            ],
            outputs=gr.JSON(label="Results"),
            title="Find Relevant Standards",
            description="Search for educational standards relevant to a learning activity.",
            api_name="find_relevant_standards",
        ),
        gr.Interface(
            fn=get_standard_details,
            inputs=gr.Textbox(label="Standard ID", placeholder="Enter a standard GUID or identifier..."),
            outputs=gr.JSON(label="Standard Details"),
            title="Get Standard Details",
            description="Retrieve full metadata for a specific standard by its ID.",
            api_name="get_standard_details",
        ),
    ],
    ["Chat", "Search", "Lookup"],
)

if __name__ == "__main__":
    logger.info("Starting Common Core MCP server")
    demo.launch(mcp_server=True)
    logger.info("Common Core MCP server started")