From a6731edcc0b778e2354516a915c02556ee1d1693 Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Tue, 25 Jun 2024 13:28:28 -0500 Subject: [PATCH 01/36] WIP: adding contexts (subsuming class config) --- src/codehelp/class_config.py | 14 +- ...g_form.html => codehelp_context_form.html} | 30 ++-- src/gened/class_config.py | 128 +++++++++++------- src/gened/classes.py | 9 ++ .../migrations/20240625--add_contexts.sql | 26 ++++ src/gened/schema_common.sql | 18 ++- .../templates/instructor_class_config.html | 34 ++++- tests/test_data.sql | 17 ++- 8 files changed, 199 insertions(+), 77 deletions(-) rename src/codehelp/templates/{codehelp_class_config_form.html => codehelp_context_form.html} (86%) create mode 100644 src/gened/migrations/20240625--add_contexts.sql diff --git a/src/codehelp/class_config.py b/src/codehelp/class_config.py index 1d934e3..d902bb8 100644 --- a/src/codehelp/class_config.py +++ b/src/codehelp/class_config.py @@ -5,8 +5,8 @@ from dataclasses import dataclass, field from flask import current_app -from gened.class_config import get_class_config as gened_get_config -from gened.class_config import ClassConfig, register_class_config +from gened.class_config import get_context as gened_get_config +from gened.class_config import ContextConfig, register_context from typing_extensions import Self from werkzeug.datastructures import ImmutableMultiDict @@ -17,8 +17,8 @@ def _default_langs() -> list[str]: @dataclass(frozen=True) -class CodeHelpClassConfig(ClassConfig): - template: str = "codehelp_class_config_form.html" +class CodeHelpContextConfig(ContextConfig): + template: str = "codehelp_context_form.html" languages: list[str] = field(default_factory=_default_langs) default_lang: str | None = None avoid: str = '' @@ -32,9 +32,9 @@ def from_request_form(cls, form: ImmutableMultiDict[str, str]) -> Self: ) -def get_class_config() -> CodeHelpClassConfig: - return gened_get_config(CodeHelpClassConfig) +def get_class_config() -> CodeHelpContextConfig: + return gened_get_config(CodeHelpContextConfig) def register_with_gened() -> None: - register_class_config(CodeHelpClassConfig) + register_context(CodeHelpContextConfig) diff --git a/src/codehelp/templates/codehelp_class_config_form.html b/src/codehelp/templates/codehelp_context_form.html similarity index 86% rename from src/codehelp/templates/codehelp_class_config_form.html rename to src/codehelp/templates/codehelp_context_form.html index 9f4a9cd..a357c13 100644 --- a/src/codehelp/templates/codehelp_class_config_form.html +++ b/src/codehelp/templates/codehelp_context_form.html @@ -4,15 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only #} -

- Queries & Responses -

-
+{% extends "base.html" %} + +{% block body %} +
+
+

Editing context '{{ context.name }}' for class {{ auth['class_name'] }}

+ +
- {% if not class_config or (class_config.languages and not class_config.default_lang) %} -

Please configure the language(s) for your class before your students use CodeHelp.

+ {% if not context_config or (context_config.languages and not context_config.default_lang) %} +

Please configure the language(s) for this context before your students use it.

{% endif %}

Add and remove language options, reorder the list, and choose a default that will be pre-selected for students who have not chosen a language previously.

@@ -20,8 +24,8 @@

- - - - - - - - - - - - - - - - -
- - NameDefaultremove
-
-
- -
-
- -
-
-
+
+
+ +

Languages, libraries, and/or frameworks that students are learning or using in this context.

+

Write one per line.

+
+
+
+
+ +
+
- +

Keywords and concepts you want the system to avoid in responses.

Write one per line.

Be careful! Writing "sum" could avoid discussing summation at all, while "sum()" will avoid just the sum function.

diff --git a/src/gened/class_config.py b/src/gened/class_config.py index 92cba71..225ff8f 100644 --- a/src/gened/class_config.py +++ b/src/gened/class_config.py @@ -7,24 +7,27 @@ from flask import ( Blueprint, - abort, current_app, - flash, - redirect, render_template, - request, - url_for, ) from werkzeug.wrappers.response import Response from .auth import get_auth, instructor_required -from .contexts import ContextConfig, context_required, have_registered_context +from .contexts import bp as contexts_bp +from .contexts import have_registered_context from .db import get_db from .openai import LLMDict, get_completion, get_models, with_llm from .tz import date_is_past bp = Blueprint('class_config', __name__, url_prefix="/instructor/config", template_folder='templates') +@bp.before_request +@instructor_required +def before_request() -> None: + """ Apply decorator to protect all class_config blueprint endpoints. """ + +bp.register_blueprint(contexts_bp) + @bp.route("/") @instructor_required @@ -62,6 +65,7 @@ def config_form() -> str: WHERE contexts.class_id=? ORDER BY contexts.class_order """, [class_id]).fetchall() + contexts = [dict(c) for c in contexts] # for conversion to json return render_template("instructor_class_config.html", class_row=class_row, link_reg_state=link_reg_state, models=get_models(), contexts=contexts) @@ -82,44 +86,3 @@ def test_llm(llm_dict: LLMDict) -> Response | dict[str, str | None]: if response_txt != "OK": current_app.logger.error(f"LLM check had no error but responded not 'OK'? Response: {response_txt}") return {'result': 'success', 'msg': 'Success!', 'error': None} - - -@bp.route("/context/") -@instructor_required -@context_required -def context_form(ctx_class: type[ContextConfig], ctx_id: int) -> str | Response: - db = get_db() - auth = get_auth() - - context_row = db.execute("SELECT * FROM contexts WHERE id=?", [ctx_id]).fetchone() - - # verify the current user can edit this context - class_id = auth['class_id'] - if context_row['class_id'] != class_id: - return abort(403) - - context_config = ctx_class.from_row(context_row) - - return render_template(context_config.template, context=context_row, context_config=context_config) - - -@bp.route("/context/set/", methods=["POST"]) -@instructor_required -@context_required -def set_context(ctx_class: type[ContextConfig], ctx_id: int) -> Response: - db = get_db() - auth = get_auth() - - # verify the current user can edit this context - class_id = auth['class_id'] - context = db.execute("SELECT * FROM contexts WHERE id=?", [ctx_id]).fetchone() - if context['class_id'] != class_id: - return abort(403) - - context_json = ctx_class.from_request_form(context['name'], request.form).to_json() - - db.execute("UPDATE contexts SET config=? WHERE id=?", [context_json, ctx_id]) - db.commit() - - flash(f"Configuration for context '{context['name']}' set!", "success") - return redirect(url_for(".config_form")) diff --git a/src/gened/classes.py b/src/gened/classes.py index c7e7714..ee3789a 100644 --- a/src/gened/classes.py +++ b/src/gened/classes.py @@ -67,10 +67,6 @@ class name from the LMS cur = db.execute("INSERT INTO classes (name) VALUES (?)", [class_name]) class_id = cur.lastrowid assert class_id is not None - db.execute( - "INSERT INTO contexts (class_id, class_order, name, available, config) VALUES (?, 0, 'default', '0001-01-01', '{}')", - [class_id] - ) db.execute( "INSERT INTO classes_lti (class_id, lti_consumer_id, lti_context_id) VALUES (?, ?, ?)", [class_id, lti_consumer_id, lti_context_id] @@ -114,10 +110,6 @@ class name from the user cur = db.execute("INSERT INTO classes (name) VALUES (?)", [class_name]) class_id = cur.lastrowid assert class_id is not None - db.execute( - "INSERT INTO contexts (class_id, class_order, name, available, config) VALUES (?, 0, 'default', '0001-01-01', '{}')", - [class_id] - ) db.execute( "INSERT INTO classes_user (class_id, creator_user_id, link_ident, openai_key, link_reg_expires) VALUES (?, ?, ?, ?, ?)", [class_id, user_id, link_ident, openai_key, dt.date.min] diff --git a/src/gened/contexts.py b/src/gened/contexts.py index 00d9ba7..7e3e8b8 100644 --- a/src/gened/contexts.py +++ b/src/gened/contexts.py @@ -9,9 +9,18 @@ from sqlite3 import Row from typing import Any, ClassVar, ParamSpec, TypeVar -from flask import Response, abort +from flask import ( + Blueprint, + abort, + flash, + redirect, + render_template, + request, + url_for, +) from typing_extensions import Protocol, Self from werkzeug.datastructures import ImmutableMultiDict +from werkzeug.wrappers.response import Response from .auth import get_auth from .db import get_db @@ -23,17 +32,17 @@ # Any application can register a ContextConfig dataclass with this module using # `register_context()` to add an application-specific section for contexts to # the instructor class configuration form. The application itself must provide -# a template for a context configuration set/update form (specified in the -# `template` attribute of the dataclass). The class_config module handles that -# form's rendering (`context_form()`) and submission (`set_context()`). The -# application itself must implement any other logic for using the contexts -# throughout the app. +# a template for a context configuration create/update form (specified in the +# `template` attribute of the dataclass). Routes in this module handle that +# form's rendering and submission. The application itself must implement any +# other logic for using the contexts throughout the app. # # App-specific context configuration data are stored in dataclasses. The -# dataclass must specify the template filename, define the config's fields and -# their types, and implement a `from_request_form()` class method that -# generates a config object based on inputs in request.form (as submitted from -# the form in the specified template). +# dataclass must specify the template filename, contain the context's name, +# define the config's fields and their types, and implement a +# `from_request_form()` class method that generates a config object based on +# inputs in request.form (as submitted from the form in the specified +# template). # For type checking classes for storing a class configuration: class ContextConfig(Protocol): @@ -46,7 +55,7 @@ class ContextConfig(Protocol): # Instantiate from a request form (must be implemented by application): @classmethod - def from_request_form(cls, name: str, form: ImmutableMultiDict[str, str]) -> Self: + def from_request_form(cls, form: ImmutableMultiDict[str, str]) -> Self: ... # Instantiate from an SQLite row (implemented here) (requires correct field @@ -99,6 +108,131 @@ def decorated_function(*args: P.args, **kwargs: P.kwargs) -> Response | R: return decorated_function +def check_valid_context(f: Callable[P, R]) -> Callable[P, Response | R]: + """ Decorator to wrap a route that takes a ctx_id parameter and make it + return a 403 if that is not a contxt in the current class. + + Typically used with @instructor_required, in which case this guarantees the + current user is allowed to edit the specified context. + + Assigns a `ctx_row` named argument carrying the context's db row. + """ + @wraps(f) + def decorated_function(*args: P.args, **kwargs: P.kwargs) -> Response | R: + db = get_db() + auth = get_auth() + + # verify the given context is in the user's current class + class_id = auth['class_id'] + ctx_id = kwargs['ctx_id'] + context_row = db.execute("SELECT * FROM contexts WHERE id=?", [ctx_id]).fetchone() + if context_row['class_id'] != class_id: + return abort(403) + + kwargs['ctx_row'] = context_row + return f(*args, **kwargs) + return decorated_function + + +### Blueprint + routes + +# Should be registered under class_config.bp +# Will be protected by @instructor_required applied to +# class_config.bp.before_request() +bp = Blueprint('contexts', __name__, url_prefix="/context", template_folder='templates') + + +@bp.route("/edit/", methods=[]) # just for url_for() in js code +@bp.route("/edit/") +@context_required +@check_valid_context +def context_form(ctx_class: type[ContextConfig], ctx_row: Row, ctx_id: int) -> str | Response: # noqa: ARG001 - ctx_id required/provided by route + context_config = ctx_class.from_row(ctx_row) + return render_template(context_config.template, context=ctx_row, context_config=context_config) + + +@bp.route("/new") +@context_required +def new_context_form(ctx_class: type[ContextConfig]) -> str | Response: + context_config = ctx_class() + return render_template(context_config.template, context=None, context_config=None) + + +def _insert_context(class_id: int, name: str, config: str, available: str) -> None: + db = get_db() + + # names must be uniquie within a class: check/look for an unused name + new_name = name + i = 0 + while db.execute("SELECT id FROM contexts WHERE class_id=? AND name=?", [class_id, new_name]).fetchone(): + i += 1 + new_name = f"{name} (copy{f' {i}' if i > 1 else ''})" + + db.execute(""" + INSERT INTO contexts (class_id, name, config, available, class_order) + VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(class_order)+1, 0) FROM contexts WHERE class_id=?)) + """, [class_id, new_name, config, available, class_id]) + db.commit() + + flash(f"Context '{new_name}' created.", "success") + + +@bp.route("/create", methods=["POST"]) +@context_required +def create_context(ctx_class: type[ContextConfig]) -> Response: + auth = get_auth() + assert auth['class_id'] + + context = ctx_class.from_request_form(request.form) + _insert_context(auth['class_id'], context.name, context.to_json(), "0001-01-01") + return redirect(url_for("class_config.config_form")) + + +@bp.route("/copy/", methods=[]) # just for url_for() in js code +@bp.route("/copy/", methods=["POST"]) +@context_required +@check_valid_context +def copy_context(ctx_class: type[ContextConfig], ctx_row: Row, ctx_id: int) -> Response: + auth = get_auth() + assert auth['class_id'] + + # passign existing name, but _insert_context will take care of finding + # a new, unused name in the class. + _insert_context(auth['class_id'], ctx_row['name'], ctx_row['config'], ctx_row['available']) + return redirect(url_for("class_config.config_form")) + + +@bp.route("/update/", methods=["POST"]) +@context_required +@check_valid_context +def update_context(ctx_class: type[ContextConfig], ctx_id: int, ctx_row: Row) -> Response: + db = get_db() + + context_json = ctx_class.from_request_form(request.form).to_json() + + db.execute("UPDATE contexts SET config=? WHERE id=?", [context_json, ctx_id]) + db.commit() + + flash(f"Configuration for context '{ctx_row['name']}' updated.", "success") + return redirect(url_for("class_config.config_form")) + + +@bp.route("/delete/", methods=[]) # just for url_for() in js code +@bp.route("/delete/", methods=["POST"]) +@context_required +@check_valid_context +def delete_context(ctx_class: type[ContextConfig], ctx_id: int, ctx_row: Row) -> Response: + db = get_db() + + db.execute("DELETE FROM contexts WHERE id=?", [ctx_id]) + db.commit() + + flash(f"Configuration for context '{ctx_row['name']}' deleted.", "success") + return redirect(url_for("class_config.config_form")) + + +### Helper functions for applications + T = TypeVar('T', bound='ContextConfig') def get_available_contexts(ctx_class: type[T]) -> list[T]: diff --git a/src/gened/migrations/20240625--add_contexts.sql b/src/gened/migrations/20240625--add_contexts.sql index fe1deba..f399c82 100644 --- a/src/gened/migrations/20240625--add_contexts.sql +++ b/src/gened/migrations/20240625--add_contexts.sql @@ -15,10 +15,12 @@ CREATE TABLE contexts ( created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(class_id) REFERENCES classes(id) ); +-- names must be unique within a class, and we often look up by class and name +CREATE UNIQUE INDEX contexts_by_class_name ON contexts(class_id, name); -- Copy existing class configs into contexts INSERT INTO contexts (name, class_id, class_order, available, config) - SELECT 'default', classes.id, 0, "0001-01-01", classes.config FROM classes; + SELECT 'Default', classes.id, 0, "0001-01-01", classes.config FROM classes; -- Remove class config column ALTER TABLE classes DROP COLUMN config; diff --git a/src/gened/schema_common.sql b/src/gened/schema_common.sql index d3bab0b..8db5205 100644 --- a/src/gened/schema_common.sql +++ b/src/gened/schema_common.sql @@ -136,6 +136,9 @@ CREATE TABLE contexts ( created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(class_id) REFERENCES classes(id) ); +-- names must be unique within a class, and we often look up by class and name +DROP INDEX IF EXISTS contexts_by_class_name; +CREATE UNIQUE INDEX contexts_by_class_name ON contexts(class_id, name); -- Roles for users in classes CREATE TABLE roles ( diff --git a/src/gened/templates/icons.html b/src/gened/templates/icons.html index ebb16fe..7f4b26c 100644 --- a/src/gened/templates/icons.html +++ b/src/gened/templates/icons.html @@ -38,4 +38,6 @@ + + diff --git a/src/gened/templates/instructor_class_config.html b/src/gened/templates/instructor_class_config.html index 4c3be0a..1066518 100644 --- a/src/gened/templates/instructor_class_config.html +++ b/src/gened/templates/instructor_class_config.html @@ -36,10 +36,6 @@ margin: 0.5rem; margin-top: 0.8rem; } -.conf_col table input[type='radio'] { - transform: scale(1.5); - margin: 0; -}

{{ auth['class_name'] }} Configuration

@@ -236,16 +232,55 @@

Language Model

- {% if contexts %} + {% if contexts != None %}
-
+

Contexts

-

Contexts let you provide additional information to the LLM for each query a student makes. You can have a single default context that is always used, or you can create separate contexts for individual assignments, modules, etc. that can be selected by students when making a query. Usually, providing an LLM with more specific context produces better, more accurate responses.

- +

Contexts let you provide additional information to the LLM for each query a student makes. You can have a single default context that is always used, or you can create separate contexts for individual assignments, modules, etc. that can be selected by students when making a query. Usually, providing more specific context produces better, more accurate responses.

+ {% if contexts | length == 0 %} +

While not strictly required, we recommend defining at least one context ("Default") to specify the language(s), frameworks, and/or libraries in use in this class.

+ {% endif %} + +
- @@ -254,20 +289,39 @@

Contexts

- {% for ctx in contexts %} - - - - -
+ Name
{{ ctx.class_order }}{{ ctx.name }}{{ ctx.available }} - edit - copy - delete + +
No contexts defined.
+
{% endif %} From c4c3f5e6bde41bd2be5c9e3c3e9348912c64e91e Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Thu, 27 Jun 2024 01:10:14 -0500 Subject: [PATCH 05/36] WIP: a few bugfixes and tweaks. --- src/codehelp/templates/codehelp_context_form.html | 6 +++--- src/gened/contexts.py | 7 +++---- src/gened/templates/instructor_class_config.html | 6 +++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/codehelp/templates/codehelp_context_form.html b/src/codehelp/templates/codehelp_context_form.html index de62575..e1f8458 100644 --- a/src/codehelp/templates/codehelp_context_form.html +++ b/src/codehelp/templates/codehelp_context_form.html @@ -20,7 +20,7 @@

Create context for class {{ auth['class_name'] }}

- +
@@ -33,14 +33,14 @@

Create context for class {{ auth['class_name'] }}

- +

Languages, libraries, and/or frameworks that students are learning or using in this context.

Write one per line.

- +
diff --git a/src/gened/contexts.py b/src/gened/contexts.py index 7e3e8b8..490ec3e 100644 --- a/src/gened/contexts.py +++ b/src/gened/contexts.py @@ -154,8 +154,7 @@ def context_form(ctx_class: type[ContextConfig], ctx_row: Row, ctx_id: int) -> s @bp.route("/new") @context_required def new_context_form(ctx_class: type[ContextConfig]) -> str | Response: - context_config = ctx_class() - return render_template(context_config.template, context=None, context_config=None) + return render_template(ctx_class.template, context=None, context_config=None) def _insert_context(class_id: int, name: str, config: str, available: str) -> None: @@ -208,9 +207,9 @@ def copy_context(ctx_class: type[ContextConfig], ctx_row: Row, ctx_id: int) -> R def update_context(ctx_class: type[ContextConfig], ctx_id: int, ctx_row: Row) -> Response: db = get_db() - context_json = ctx_class.from_request_form(request.form).to_json() + context = ctx_class.from_request_form(request.form) - db.execute("UPDATE contexts SET config=? WHERE id=?", [context_json, ctx_id]) + db.execute("UPDATE contexts SET name=?, config=? WHERE id=?", [context.name, context.to_json(), ctx_id]) db.commit() flash(f"Configuration for context '{ctx_row['name']}' updated.", "success") diff --git a/src/gened/templates/instructor_class_config.html b/src/gened/templates/instructor_class_config.html index 1066518..a7b44e8 100644 --- a/src/gened/templates/instructor_class_config.html +++ b/src/gened/templates/instructor_class_config.html @@ -298,13 +298,13 @@

Contexts

- + - - From 26a337b1e2fe0121813067bbc72269355197efdf Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Thu, 27 Jun 2024 13:24:17 -0500 Subject: [PATCH 06/36] WIP: more work, focusing on queries. --- src/codehelp/context.py | 43 ++++++++++++++++--- src/codehelp/docs/manual_class_creation.md | 2 - src/codehelp/helper.py | 26 +++++------ .../20240625--codehelp--add_contexts.sql | 30 ++++++++++++- .../20240626--update_context_configs.sql | 32 -------------- src/codehelp/templates/admin.html | 2 +- .../templates/codehelp_context_form.html | 4 +- src/codehelp/templates/help_form.html | 18 ++++++-- src/codehelp/templates/help_view.html | 6 +-- src/codehelp/templates/instructor.html | 2 +- src/codehelp/templates/recent_queries.html | 15 +++++-- src/gened/contexts.py | 17 +++++--- 12 files changed, 124 insertions(+), 73 deletions(-) delete mode 100644 src/codehelp/migrations/20240626--update_context_configs.sql diff --git a/src/codehelp/context.py b/src/codehelp/context.py index 6d03355..e37ef56 100644 --- a/src/codehelp/context.py +++ b/src/codehelp/context.py @@ -6,6 +6,7 @@ from flask import current_app from gened.contexts import ContextConfig, register_context +from jinja2 import Environment from typing_extensions import Self from werkzeug.datastructures import ImmutableMultiDict @@ -14,11 +15,17 @@ def _default_langs() -> list[str]: langs: list[str] = current_app.config['DEFAULT_LANGUAGES'] # declaration keeps mypy happy return langs +jinja_env = Environment( + trim_blocks=True, + lstrip_blocks=True, + autoescape=True, +) + @dataclass(frozen=True) class CodeHelpContext(ContextConfig): name: str - languages: str | None = None + tools: str | None = None avoid: str | None = None template: str = "codehelp_context_form.html" @@ -26,14 +33,40 @@ class CodeHelpContext(ContextConfig): def from_request_form(cls, form: ImmutableMultiDict[str, str]) -> Self: return cls( name=form['name'], - languages=form.get('languages', None), + tools=form.get('tools', None), avoid=form.get('avoid', None), ) - def to_str(self) -> str: + @staticmethod + def _list_fmt(s: str | None) -> str: + if s: + return ', '.join(s.split('\n')) + else: + return '' + + def prompt_str(self) -> str: """ Convert this context into a string to be used in an LLM prompt. """ - # TODO: this is a draft -- add more - return f"{self.languages}{self.avoid}" + template = jinja_env.from_string("""\ +{% if tools %} +Environment and tools: {{ tools }} +{% endif %} +{% if avoid %} +Keywords and concepts to avoid (do not mention these in your response at all): {{ avoid }} +{% endif %} +""") + return template.render(tools=self._list_fmt(self.tools), avoid=self._list_fmt(self.avoid)) + + def desc_html(self) -> str: + """ Convert this context into a description for users in HTML. """ + template = jinja_env.from_string("""\ +{% if tools %} +

Environment & tools: {{ tools }}

+{% endif %} +{% if avoid %} +

Avoid: {{ avoid }}

+{% endif %} +""") + return template.render(tools=self._list_fmt(self.tools), avoid=self._list_fmt(self.avoid)) def register_with_gened() -> None: diff --git a/src/codehelp/docs/manual_class_creation.md b/src/codehelp/docs/manual_class_creation.md index f52246d..97364cb 100644 --- a/src/codehelp/docs/manual_class_creation.md +++ b/src/codehelp/docs/manual_class_creation.md @@ -39,8 +39,6 @@ Anyone using the link will register as a student in the class if "Registration v You can control whether registration is allowed by manually enabling and disabling it or by setting a date up through which registration will be enabled. Once students have registered, they can use the same link to access CodeHelp and make queries connected to your class. -Before your students can use CodeHelp, you will need to provide a configuration under "Queries & Responses," at least selecting a default language. - In the configuration screen, you can also archive the class (so students can see their past queries but not make new ones) and change or delete the OpenAI API key you have connected to it. ## Adding Instructors diff --git a/src/codehelp/helper.py b/src/codehelp/helper.py index e53f53d..80858ac 100644 --- a/src/codehelp/helper.py +++ b/src/codehelp/helper.py @@ -49,27 +49,29 @@ def help_form(query_id: int | None = None) -> str: db = get_db() auth = get_auth() contexts = get_available_contexts(CodeHelpContext) + contexts_desc = {ctx.name: ctx.desc_html() for ctx in contexts} selected_context_name = None - # Select most recently used context, if available - recent_row = db.execute("SELECT context_name FROM queries WHERE queries.user_id=? ORDER BY query_time DESC LIMIT 1", [auth['user_id']]).fetchone() - if recent_row: - selected_context_name = recent_row['context_name'] - - # populate with a query+response if one is specified in the query string - query_row = None if query_id is not None: + # populate with a query if one is specified in the query string query_row, _ = get_query(query_id) # _ because we don't need responses here if query_row is not None: selected_context_name = query_row['context_name'] + else: + # no query specified, + query_row = None + # but we can pre-select the most recently used context, if available + recent_row = db.execute("SELECT context_name FROM queries WHERE queries.user_id=? ORDER BY query_time DESC LIMIT 1", [auth['user_id']]).fetchone() + if recent_row: + selected_context_name = recent_row['context_name'] # validate selected context name (may no longer exist / be available) - if not any(selected_context_name == ctx.name for ctx in contexts): + if selected_context_name not in contexts_desc: selected_context_name = None history = get_history() - return render_template("help_form.html", query=query_row, history=history, contexts=contexts, selected_context_name=selected_context_name) + return render_template("help_form.html", query=query_row, history=history, contexts=contexts, contexts_desc=contexts_desc, selected_context_name=selected_context_name) @bp.route("/view/") @@ -115,10 +117,10 @@ async def run_query_prompts(llm_dict: LLMDict, context: CodeHelpContext | None, client = llm_dict['client'] model = llm_dict['model'] - context_str = context.to_str() if context is not None else None + context_str = context.prompt_str() if context is not None else None # create "avoid set" from context - if context is not None: + if context is not None and context.avoid: avoid_set = {x.strip() for x in context.avoid.split('\n') if x.strip() != ''} else: avoid_set = set() @@ -186,7 +188,7 @@ def record_query(context: CodeHelpContext | None, code: str, error: str, issue: role_id = auth['role_id'] context_name = context.name if context is not None else None - context_str = context.to_str if context is not None else None + context_str = context.prompt_str() if context is not None else None cur = db.execute( "INSERT INTO queries (context_name, context, code, error, issue, user_id, role_id) VALUES (?, ?, ?, ?, ?, ?, ?)", diff --git a/src/codehelp/migrations/20240625--codehelp--add_contexts.sql b/src/codehelp/migrations/20240625--codehelp--add_contexts.sql index f7b5508..03df690 100644 --- a/src/codehelp/migrations/20240625--codehelp--add_contexts.sql +++ b/src/codehelp/migrations/20240625--codehelp--add_contexts.sql @@ -6,8 +6,36 @@ BEGIN; ALTER TABLE queries ADD COLUMN context_name TEXT; ALTER TABLE queries ADD COLUMN context TEXT; -UPDATE queries SET context_name="migrated"; +UPDATE queries SET context_name=NULL; UPDATE queries SET context=language; ALTER TABLE queries DROP COLUMN language; +-- Move context configs over to new format +-- [let's not do this too often...] +UPDATE contexts +SET config = newtbl.newconfig +FROM ( + SELECT + contexts.id, + json_remove( + json_remove( + json_set( + contexts.config, + '$.tools', + COALESCE( + group_concat(atom, char(10)), + json_extract(contexts.config, '$.default_lang') + ) + ), + '$.default_lang' + ), + '$.languages' + ) AS newconfig + FROM + contexts + LEFT JOIN json_each(json_extract(contexts.config, '$.languages')) + GROUP BY contexts.id +) AS newtbl +WHERE newtbl.id=contexts.id; + COMMIT; diff --git a/src/codehelp/migrations/20240626--update_context_configs.sql b/src/codehelp/migrations/20240626--update_context_configs.sql deleted file mode 100644 index afeb3e6..0000000 --- a/src/codehelp/migrations/20240626--update_context_configs.sql +++ /dev/null @@ -1,32 +0,0 @@ --- SPDX-FileCopyrightText: 2024 Mark Liffiton --- --- SPDX-License-Identifier: AGPL-3.0-only - -BEGIN; - --- Move context configs over to new format --- [let's not do this too often...] -UPDATE contexts -SET config = newtbl.newconfig -FROM ( - SELECT - contexts.id, - json_remove( - json_set( - contexts.config, - '$.languages', - COALESCE( - group_concat(atom, char(10)), - json_extract(contexts.config, '$.default_lang') - ) - ), - '$.default_lang' - ) AS newconfig - FROM - contexts - LEFT JOIN json_each(json_extract(contexts.config, '$.languages')) - GROUP BY contexts.id -) AS newtbl -WHERE newtbl.id=contexts.id; - -COMMIT; diff --git a/src/codehelp/templates/admin.html b/src/codehelp/templates/admin.html index 07b9cc6..08b715e 100644 --- a/src/codehelp/templates/admin.html +++ b/src/codehelp/templates/admin.html @@ -9,7 +9,7 @@ {% block admin_queries %} {{ datatable( 'queries', - [('id', 'id'), ('user', 'display_name'), ('time', 'query_time'), ('language', 'language'), ('code', 'code'), ('error', 'error'), ('issue', 'issue'), ('response', 'response_text'), ('helpful', 'helpful_emoji')], + [('id', 'id'), ('user', 'display_name'), ('time', 'query_time'), ('context', 'context_name'), ('code', 'code'), ('error', 'error'), ('issue', 'issue'), ('response', 'response_text'), ('helpful', 'helpful_emoji')], queries, link_col=0, link_template="/help/view/${value}", diff --git a/src/codehelp/templates/codehelp_context_form.html b/src/codehelp/templates/codehelp_context_form.html index e1f8458..3c9b0e4 100644 --- a/src/codehelp/templates/codehelp_context_form.html +++ b/src/codehelp/templates/codehelp_context_form.html @@ -33,14 +33,14 @@

Create context for class {{ auth['class_name'] }}

- +

Languages, libraries, and/or frameworks that students are learning or using in this context.

Write one per line.

- +
diff --git a/src/codehelp/templates/help_form.html b/src/codehelp/templates/help_form.html index 1e31cf9..1a02731 100644 --- a/src/codehelp/templates/help_form.html +++ b/src/codehelp/templates/help_form.html @@ -17,7 +17,13 @@
{% if contexts %} -
+ +
@@ -26,18 +32,22 @@ {{ contexts[0].name }} {% else %} -
+
- {% for ctx in contexts %} - + {% endfor %}
+
+
+
+
{% endif %}
diff --git a/src/codehelp/templates/help_view.html b/src/codehelp/templates/help_view.html index ee6e8ef..0d7f859 100644 --- a/src/codehelp/templates/help_view.html +++ b/src/codehelp/templates/help_view.html @@ -34,13 +34,13 @@
- {% if query.language %} + {% if query.context_name %}
- +
- {{ query.language }} + {{ query.context_name }}
{% endif %} diff --git a/src/codehelp/templates/instructor.html b/src/codehelp/templates/instructor.html index c259740..f28208f 100644 --- a/src/codehelp/templates/instructor.html +++ b/src/codehelp/templates/instructor.html @@ -12,7 +12,7 @@ ('id', 'id'), ('user', 'display_name'), ('time', 'query_time'), - ('lang', 'language'), + ('context', 'context_name'), ('code', 'code'), ('error', 'error'), ('issue', 'issue'), diff --git a/src/codehelp/templates/recent_queries.html b/src/codehelp/templates/recent_queries.html index c69892e..d83d14f 100644 --- a/src/codehelp/templates/recent_queries.html +++ b/src/codehelp/templates/recent_queries.html @@ -13,9 +13,18 @@

Your recent queries:

View Retry
- {{prev.language}}: {{prev.code | truncate(50)}} | {{prev.error | truncate(50)}} -
- 100 %}title="{{prev.issue}}"{% endif %}>{{prev.issue | truncate(100)}} + {% if prev.context_name %} +

{{ prev.context_name }}:

+ {% endif %} + {% if prev.code %} +

{{prev.code | truncate(50)}}

+ {% endif %} + {% if prev.error %} +

{{prev.error | truncate(50)}}

+ {% endif %} + {% if prev.issue %} +

100 %}title="{{prev.issue}}"{% endif %}>{{prev.issue | truncate(100)}}

+ {% endif %}
{% else %}

No previous queries...

diff --git a/src/gened/contexts.py b/src/gened/contexts.py index 490ec3e..7269075 100644 --- a/src/gened/contexts.py +++ b/src/gened/contexts.py @@ -157,7 +157,7 @@ def new_context_form(ctx_class: type[ContextConfig]) -> str | Response: return render_template(ctx_class.template, context=None, context_config=None) -def _insert_context(class_id: int, name: str, config: str, available: str) -> None: +def _insert_context(class_id: int, name: str, config: str, available: str) -> int: db = get_db() # names must be uniquie within a class: check/look for an unused name @@ -165,16 +165,19 @@ def _insert_context(class_id: int, name: str, config: str, available: str) -> No i = 0 while db.execute("SELECT id FROM contexts WHERE class_id=? AND name=?", [class_id, new_name]).fetchone(): i += 1 - new_name = f"{name} (copy{f' {i}' if i > 1 else ''})" + new_name = f"{name} ({i})" - db.execute(""" + cur = db.execute(""" INSERT INTO contexts (class_id, name, config, available, class_order) VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(class_order)+1, 0) FROM contexts WHERE class_id=?)) """, [class_id, new_name, config, available, class_id]) db.commit() + new_ctx_id = cur.lastrowid flash(f"Context '{new_name}' created.", "success") + return new_ctx_id + @bp.route("/create", methods=["POST"]) @context_required @@ -195,7 +198,7 @@ def copy_context(ctx_class: type[ContextConfig], ctx_row: Row, ctx_id: int) -> R auth = get_auth() assert auth['class_id'] - # passign existing name, but _insert_context will take care of finding + # passing existing name, but _insert_context will take care of finding # a new, unused name in the class. _insert_context(auth['class_id'], ctx_row['name'], ctx_row['config'], ctx_row['available']) return redirect(url_for("class_config.config_form")) @@ -240,7 +243,7 @@ def get_available_contexts(ctx_class: type[T]) -> list[T]: class_id = auth['class_id'] # TODO: filter by available using current date - context_rows = db.execute("SELECT * FROM contexts WHERE class_id=?", [class_id]).fetchall() + context_rows = db.execute("SELECT * FROM contexts WHERE class_id=? ORDER BY class_order ASC", [class_id]).fetchall() return [ctx_class.from_row(row) for row in context_rows] @@ -257,7 +260,7 @@ def get_context_config_by_id(ctx_class: type[T], ctx_id: int) -> T: class_id = auth['class_id'] # just for extra safety: double-check that the context is in the current class - context_row = db.execute("SELECT config FROM contexts WHERE class_id=? AND id=?", [class_id, ctx_id]).fetchone() + context_row = db.execute("SELECT * FROM contexts WHERE class_id=? AND id=?", [class_id, ctx_id]).fetchone() if not context_row: raise ContextNotFoundError @@ -273,7 +276,7 @@ def get_context_by_name(ctx_class: type[T], ctx_name: str) -> T: class_id = auth['class_id'] - context_row = db.execute("SELECT config FROM contexts WHERE class_id=? AND name=?", [class_id, ctx_name]).fetchone() + context_row = db.execute("SELECT * FROM contexts WHERE class_id=? AND name=?", [class_id, ctx_name]).fetchone() if not context_row: raise ContextNotFoundError From deb7b76681bed42eedc5b9a0771054064e174224 Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Thu, 27 Jun 2024 16:13:34 -0500 Subject: [PATCH 07/36] WIP: displaying contexts, context chooser, contexts in tutor chats. --- src/codehelp/__init__.py | 5 ++- src/codehelp/context.py | 37 +++++++++++------ src/codehelp/helper.py | 15 +++++-- src/codehelp/prompts.py | 2 +- .../templates/codehelp_context_form.html | 19 ++++++++- src/codehelp/templates/context_chooser.html | 30 ++++++++++++++ src/codehelp/templates/help_form.html | 32 +-------------- src/codehelp/templates/tutor_new_form.html | 12 ++---- src/codehelp/tutor.py | 40 +++++++++++++++---- 9 files changed, 127 insertions(+), 65 deletions(-) create mode 100644 src/codehelp/templates/context_chooser.html diff --git a/src/codehelp/__init__.py b/src/codehelp/__init__.py index c07e9d6..c5bcd75 100644 --- a/src/codehelp/__init__.py +++ b/src/codehelp/__init__.py @@ -49,8 +49,9 @@ def create_app(test_config: dict[str, Any] | None = None, instance_path: Path | app.register_blueprint(helper.bp) app.register_blueprint(tutor.bp) - # register our custom class configuration with Gen-Ed - context.register_with_gened() + # register our custom context configuration with Gen-Ed + # and grab a reference to the app's markdown filter + context.init_app(app) # register app-specific charts in the admin interface admin.register_with_gened() diff --git a/src/codehelp/context.py b/src/codehelp/context.py index e37ef56..a1b1a7d 100644 --- a/src/codehelp/context.py +++ b/src/codehelp/context.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from flask import current_app +from flask import current_app, Flask from gened.contexts import ContextConfig, register_context from jinja2 import Environment from typing_extensions import Self @@ -25,20 +25,22 @@ def _default_langs() -> list[str]: @dataclass(frozen=True) class CodeHelpContext(ContextConfig): name: str - tools: str | None = None - avoid: str | None = None + tools: str = '' + details: str = '' + avoid: str = '' template: str = "codehelp_context_form.html" @classmethod def from_request_form(cls, form: ImmutableMultiDict[str, str]) -> Self: return cls( name=form['name'], - tools=form.get('tools', None), - avoid=form.get('avoid', None), + tools=form.get('tools', ''), + details=form.get('details', ''), + avoid=form.get('avoid', ''), ) @staticmethod - def _list_fmt(s: str | None) -> str: + def _list_fmt(s: str) -> str: if s: return ', '.join(s.split('\n')) else: @@ -50,24 +52,35 @@ def prompt_str(self) -> str: {% if tools %} Environment and tools: {{ tools }} {% endif %} +{% if details %} +Details:
{{ details }}
+{% endif %} {% if avoid %} Keywords and concepts to avoid (do not mention these in your response at all): {{ avoid }} {% endif %} """) - return template.render(tools=self._list_fmt(self.tools), avoid=self._list_fmt(self.avoid)) + return template.render(tools=self._list_fmt(self.tools), details=self.details, avoid=self._list_fmt(self.avoid)) def desc_html(self) -> str: - """ Convert this context into a description for users in HTML. """ + """ Convert this context into a description for users in HTML. + + Does not include the avoid set (not necessary to show students). + """ template = jinja_env.from_string("""\ {% if tools %}

Environment & tools: {{ tools }}

{% endif %} -{% if avoid %} -

Avoid: {{ avoid }}

+{% if details %} +

Details:

+{{ details | markdown }} {% endif %} """) - return template.render(tools=self._list_fmt(self.tools), avoid=self._list_fmt(self.avoid)) + return template.render(tools=self._list_fmt(self.tools), details=self.details, avoid=self._list_fmt(self.avoid)) -def register_with_gened() -> None: +def init_app(app: Flask) -> None: + """ Register the custom context class with Gen-Ed, + and grab a copy of the app's markdown filter for use here. + """ register_context(CodeHelpContext) + jinja_env.filters['markdown'] = app.jinja_env.filters['markdown'] diff --git a/src/codehelp/helper.py b/src/codehelp/helper.py index 80858ac..079f734 100644 --- a/src/codehelp/helper.py +++ b/src/codehelp/helper.py @@ -48,8 +48,11 @@ def help_form(query_id: int | None = None) -> str: db = get_db() auth = get_auth() - contexts = get_available_contexts(CodeHelpContext) - contexts_desc = {ctx.name: ctx.desc_html() for ctx in contexts} + + contexts_list = get_available_contexts(CodeHelpContext) + # turn into format we can pass to js via JSON + contexts = {ctx.name: ctx.desc_html() for ctx in contexts_list} + selected_context_name = None if query_id is not None: @@ -66,12 +69,16 @@ def help_form(query_id: int | None = None) -> str: selected_context_name = recent_row['context_name'] # validate selected context name (may no longer exist / be available) - if selected_context_name not in contexts_desc: + if selected_context_name not in contexts: selected_context_name = None + # regardless, if there is only one context, select it + if len(contexts) == 1: + selected_context_name = next(iter(contexts.keys())) + history = get_history() - return render_template("help_form.html", query=query_row, history=history, contexts=contexts, contexts_desc=contexts_desc, selected_context_name=selected_context_name) + return render_template("help_form.html", query=query_row, history=history, contexts=contexts, selected_context_name=selected_context_name) @bp.route("/view/") diff --git a/src/codehelp/prompts.py b/src/codehelp/prompts.py index 111ef71..8199b2f 100644 --- a/src/codehelp/prompts.py +++ b/src/codehelp/prompts.py @@ -156,7 +156,7 @@ def make_topics_prompt(code: str, error: str, issue: str, context: str | None, r If the topic is broad and it could take more than one chat session to cover all aspects of it, first ask the student to clarify what, specifically, they are attempting to learn about it. {% if context %} -Additional context that may be relevant to this chat: +Additional context provided by the instructor that may be relevant to this chat: {{ context }} diff --git a/src/codehelp/templates/codehelp_context_form.html b/src/codehelp/templates/codehelp_context_form.html index 3c9b0e4..c233c74 100644 --- a/src/codehelp/templates/codehelp_context_form.html +++ b/src/codehelp/templates/codehelp_context_form.html @@ -33,7 +33,7 @@

Create context for class {{ auth['class_name'] }}

- +

Languages, libraries, and/or frameworks that students are learning or using in this context.

Write one per line.

@@ -46,11 +46,28 @@

Create context for class {{ auth['class_name'] }}

+
+
+ +

Additional important details for this context. This could include assignment specifications, background knowledge the students can be expected to have, or other sorts of guidance.

+

You can use markdown formatting.

+

This will be shown to students.

+
+
+
+
+ +
+
+
+
+

Keywords and concepts you want the system to avoid in responses.

Write one per line.

+

This will not be shown to students.

Be careful! Writing "sum" could avoid discussing summation at all, while "sum()" will avoid just the sum function.

diff --git a/src/codehelp/templates/context_chooser.html b/src/codehelp/templates/context_chooser.html new file mode 100644 index 0000000..bd5dcdd --- /dev/null +++ b/src/codehelp/templates/context_chooser.html @@ -0,0 +1,30 @@ + +
+
+
+ {% if contexts | length == 1 %} + {% set the_ctx = (contexts.keys() | list)[0] %} + + {{ the_ctx }} + {% else %} +
+ +
+ {% endif %} +
+
+
+
+
+
+
diff --git a/src/codehelp/templates/help_form.html b/src/codehelp/templates/help_form.html index 1a02731..914fa96 100644 --- a/src/codehelp/templates/help_form.html +++ b/src/codehelp/templates/help_form.html @@ -17,39 +17,11 @@ {% if contexts %} - -
+
-
- {% if contexts | length == 1 %} - - {{ contexts[0].name }} - {% else %} -
-
-
- -
-
-
-
-
-
-
- {% endif %} -
+ {% include "context_chooser.html" %}
{% endif %} diff --git a/src/codehelp/templates/tutor_new_form.html b/src/codehelp/templates/tutor_new_form.html index 40b4793..7e226ca 100644 --- a/src/codehelp/templates/tutor_new_form.html +++ b/src/codehelp/templates/tutor_new_form.html @@ -30,18 +30,14 @@

Start a new Tutor chat

+ {% if contexts %}
-
+
-
-
-
- -
-
-
+ {% include "context_chooser.html" %}
+ {% endif %}
diff --git a/src/codehelp/tutor.py b/src/codehelp/tutor.py index 0acbe6d..17a8eb9 100644 --- a/src/codehelp/tutor.py +++ b/src/codehelp/tutor.py @@ -6,10 +6,23 @@ import json from sqlite3 import Row -from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask import ( + Blueprint, + flash, + make_response, + redirect, + render_template, + request, + url_for, +) from gened.admin import bp as bp_admin from gened.admin import register_admin_link from gened.auth import get_auth, login_required, tester_required +from gened.contexts import ( + ContextNotFoundError, + get_available_contexts, + get_context_by_name, +) from gened.db import get_db from gened.openai import LLMDict, get_completion, with_llm from gened.queries import get_query @@ -17,6 +30,7 @@ from werkzeug.wrappers.response import Response from . import prompts +from .context import CodeHelpContext class ChatNotFoundError(Exception): @@ -37,20 +51,31 @@ def before_request() -> None: """Apply decorators to protect all tutor blueprint endpoints. Use @tester_required first so that non-logged-in users get a 404 as well. """ - pass @bp.route("/") def tutor_form() -> str: + contexts_list = get_available_contexts(CodeHelpContext) + # turn into format we can pass to js via JSON + contexts = {ctx.name: ctx.desc_html() for ctx in contexts_list} + + selected_context_name = None + if len(contexts) == 1: + selected_context_name = next(iter(contexts.keys())) + chat_history = get_chat_history() - return render_template("tutor_new_form.html", chat_history=chat_history) + return render_template("tutor_new_form.html", contexts=contexts, selected_context_name=selected_context_name, chat_history=chat_history) @bp.route("/chat/create", methods=["POST"]) @with_llm() def start_chat(llm_dict: LLMDict) -> Response: topic = request.form['topic'] - context = request.form.get('context', None) + try: + context = get_context_by_name(CodeHelpContext, request.form['context']) + except ContextNotFoundError: + flash(f"Context not found: {request.form['context']}") + return make_response(render_template("error.html"), 400) chat_id = create_chat(topic, context) @@ -68,7 +93,7 @@ def start_chat_from_query(llm_dict: LLMDict) -> Response: query_id = int(request.form['query_id']) query_row, response = get_query(query_id) assert query_row - context = f"The user is working with the {query_row['language']} language." + context = get_context_by_name(CodeHelpContext, query_row['context_name']) chat_id = create_chat(topic, context) @@ -90,15 +115,16 @@ def chat_interface(chat_id: int) -> str: return render_template("tutor_view.html", chat_id=chat_id, topic=topic, context=context, chat=chat, chat_history=chat_history) -def create_chat(topic: str, context: str|None = None) -> int: +def create_chat(topic: str, context: CodeHelpContext) -> int: auth = get_auth() user_id = auth['user_id'] role_id = auth['role_id'] + context_str = context.prompt_str() db = get_db() cur = db.execute( "INSERT INTO tutor_chats (user_id, role_id, topic, context, chat_json) VALUES (?, ?, ?, ?, ?)", - [user_id, role_id, topic, context, json.dumps([])] + [user_id, role_id, topic, context_str, json.dumps([])] ) new_row_id = cur.lastrowid From d3518cb0b6e94776f61fd2154c38f66ebfe17650 Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Thu, 27 Jun 2024 16:47:44 -0500 Subject: [PATCH 08/36] WIP: a bit of progress on managing availability. --- src/gened/class_config.py | 2 +- src/gened/templates/instructor_class_config.html | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/gened/class_config.py b/src/gened/class_config.py index 225ff8f..fe59b71 100644 --- a/src/gened/class_config.py +++ b/src/gened/class_config.py @@ -60,7 +60,7 @@ def config_form() -> str: if have_registered_context(): # get contexts contexts = db.execute(""" - SELECT contexts.* + SELECT id, name, CAST(available AS TEXT) AS available FROM contexts WHERE contexts.class_id=? ORDER BY contexts.class_order diff --git a/src/gened/templates/instructor_class_config.html b/src/gened/templates/instructor_class_config.html index a7b44e8..1a1caa6 100644 --- a/src/gened/templates/instructor_class_config.html +++ b/src/gened/templates/instructor_class_config.html @@ -295,7 +295,9 @@

Contexts

- + + + From 18d95d55f1e134f4e76a55a1228528e4edc1947d Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Thu, 27 Jun 2024 17:49:32 -0500 Subject: [PATCH 09/36] WIP: first draft of UI for changing availability (thanks, Aider+Sonnet\!) --- .../templates/instructor_class_config.html | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/gened/templates/instructor_class_config.html b/src/gened/templates/instructor_class_config.html index 1a1caa6..3eb842c 100644 --- a/src/gened/templates/instructor_class_config.html +++ b/src/gened/templates/instructor_class_config.html @@ -296,7 +296,62 @@

Contexts

- +
+ + +
From f06bafb75478ff07d9f8eeb33ab1aca1fdf7f06f Mon Sep 17 00:00:00 2001 From: Mark Liffiton Date: Fri, 28 Jun 2024 00:08:34 -0500 Subject: [PATCH 10/36] WIP: contexts UI updates DB automatically, UI tweaks. --- src/codehelp/templates/context_chooser.html | 2 +- src/codehelp/templates/help_form.html | 2 +- src/gened/contexts.py | 45 +++++++++- src/gened/templates/icons.html | 1 + .../templates/instructor_class_config.html | 85 +++++++++++++------ 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/codehelp/templates/context_chooser.html b/src/codehelp/templates/context_chooser.html index bd5dcdd..2ccbdbd 100644 --- a/src/codehelp/templates/context_chooser.html +++ b/src/codehelp/templates/context_chooser.html @@ -10,7 +10,7 @@ {% if contexts | length == 1 %} {% set the_ctx = (contexts.keys() | list)[0] %} - {{ the_ctx }} +
{{ the_ctx }}
{% else %}