Jul 9 - Chat - Add knowledge base for AI training

This commit is contained in:
2026-07-09 15:21:21 -04:00
parent 43d6776185
commit 8d4b8b72eb
8 changed files with 438 additions and 30 deletions
+22 -2
View File
@@ -330,6 +330,15 @@ support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE, inde
Persists the customer AI support chat. `chat_message()` writes both the user turn and the assistant reply into the session (creating one lazily on the first message; `session.updated_at` bumped each turn). `chat()` reloads the customer's **most recent** session into the chat window for continuity (unless `?new=1`). Read-only history views exist for the customer (`/support/my-conversations`) and staff (`/support/admin/conversations`). `SupportChatSession.preview` = first user message; `.message_count` for list views. See §18 "Support Chat".
### SupportKnowledge (Phase 38)
```
support_knowledge: id, title VARCHAR(200), content TEXT, active BOOL,
sort_order INT, created_by (FK→users SET NULL), created_at, updated_at
```
Admin-curated knowledge entries that "train" the AI chatbot **without code changes**. `_system_prompt_with_kb()` in `routes/support.py` appends every **active** entry (ordered by `sort_order`, id) to the base `_SYSTEM_PROMPT` on each chat request, soft-capped at `_KB_MAX_CHARS` (6000). Managed by admin/director at `/support/admin/knowledge` (list/new/edit/delete). The base `_SYSTEM_PROMPT` is a comprehensive, **customer-scoped** description of the app; the KB is the incremental, non-dev-editable layer on top. The chatbot is Groq/Llama (`GROQ_MODEL`, default `llama-3.3-70b-versatile`) — **not** fine-tuned; all "knowledge" is prompt context.
**Flow:**
- Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email)
- Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/<id>`)
@@ -465,7 +474,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
| `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
| `api` | `/api/v1` | parent blueprint |
@@ -773,7 +782,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase34_facility_qr
→ phase35_issue_handler
→ phase36_scheduled_insp
→ phase37_support_chat ← HEAD
→ phase37_support_chat
→ phase38_support_knowledge ← HEAD
```
### phase21_performance_indexes
@@ -902,6 +912,16 @@ flask db upgrade
sudo systemctl restart gunicorn
```
### phase38_support_knowledge
Revision id `phase38_support_knowledge`. Creates `support_knowledge` (admin-curated AI-chat knowledge entries). Active entries are injected into the chatbot system prompt at request time by `_system_prompt_with_kb()` (soft-capped at `_KB_MAX_CHARS`). Table existence check — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
**Deploy order for phases 2432:**
```bash
flask db upgrade
+19
View File
@@ -90,3 +90,22 @@ class SupportChatMessage(db.Model):
def __repr__(self):
return f'<SupportChatMessage {self.id} session={self.session_id} role={self.role}>'
class SupportKnowledge(db.Model):
"""Admin-curated knowledge entries injected into the AI support chat's system
prompt (phase38). Lets staff 'train' the chatbot's app knowledge without code
changes each active entry is appended to the prompt on every chat request."""
__tablename__ = 'support_knowledge'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False) # topic / question
content = db.Column(db.Text, nullable=False) # the answer / knowledge
active = db.Column(db.Boolean, nullable=False, default=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
def __repr__(self):
return f'<SupportKnowledge {self.id} "{self.title[:30]}" active={self.active}>'
+194 -20
View File
@@ -7,12 +7,13 @@ from flask_login import login_required, current_user
from app import db
from app.models.support import (SupportTicket, SupportTicketReply,
SupportChatSession, SupportChatMessage)
SupportChatSession, SupportChatMessage,
SupportKnowledge)
from app.models.user import User
from app.models.facility import Facility
from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.notifications import notify
@@ -22,37 +23,132 @@ logger = logging.getLogger(__name__)
# ── Groq system prompt ────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """\
You are JQC Support, a friendly assistant for customers of JQC (Janitorial Quality Control), \
a commercial cleaning quality management platform.
You are JQC Support, a friendly assistant for CUSTOMERS of JQC (Janitorial Quality \
Control), a commercial cleaning quality-management platform used by a janitorial \
service provider and its clients. You help the client (customer) understand and use \
their portal. Only describe what a CUSTOMER can do do not tell customers they can \
perform staff-only actions (assigning issues, running inspections, editing templates, \
managing users, notification matrix, etc.).
Help customers with:
- Navigating the portal: Dashboard, Inspections, Issues, Reports pages
- Inspection scores: 90%+ = Excellent, 70-89% = Satisfactory, below 70% = Needs Improvement
- SLA timelines: Critical issues = 4 h, High = 24 h, Medium = 72 h, Low = 168 h
- Issue statuses: Open In Progress Pending Verification Resolved
- Following issues to receive email/in-app update notifications
- Reporting new cleaning concerns via the Issues > Log Issue page
- Understanding facility scorecards and trend charts in Reports
=== WHAT JQC DOES ===
The janitorial provider performs quality inspections of the customer's facilities \
against checklist templates, tracks any problems ("issues"), and shares scores and \
reports. Work is organized as: Contracts Facilities Areas. A customer only sees \
the facilities they are assigned to.
Rules:
- Keep answers concise (3-5 sentences max) and friendly.
- Never invent specific staff names, contract prices, schedules, or contact numbers.
- If the customer has an access problem, billing question, or a concern you genuinely \
cannot resolve through guidance, say so clearly and suggest they click \
"Submit to Support" to reach the admin team directly.\
=== CUSTOMER PORTAL NAVIGATION ===
- Dashboard: at-a-glance cards open issues (split by who handles them), issues \
opened/resolved today, recent inspections, and a "Your Facilities" panel with search.
- Facilities: the customer's assigned facilities; open one to see its details, areas, \
scorecard, and QR code.
- Inspections: completed and in-progress inspections at their facilities, with scores; \
open one to see the checklist results and any flagged issues.
- Issues: all cleaning issues at their facilities; filter by status, severity, facility, \
date. Customers can log a new issue here.
- Reports: facility scorecards, score trends, "Avg Score by Facility" (filterable by \
Contract), and downloadable PDF summaries.
- Support: this AI chat (Ask a Question), My Conversations (saved chats), and \
My Requests (support tickets they submitted).
=== INSPECTION SCORES ===
Each completed inspection has an overall score (0100%). Interpretation:
- 90%+ = Excellent, 8089% = Good, 7079% = Fair/Satisfactory, below 70% = Needs Improvement.
Scorecards and the Reports page show a facility's average score and its trend over time. \
Note: checklist items left unanswered (score 0) are excluded from the average.
=== ISSUES ===
- Lifecycle (status): Open In Progress Pending Verification Resolved.
- Severity: Critical, High, Medium, Low this drives the SLA (resolution target).
- "Handled By" tells you who is resolving it:
* Janitorial Staff the cleaning provider's own crew.
* Facility Staff the facility's own on-site staff are handling it.
* External Vendor an outside contractor was engaged.
In every case a member of the provider's team stays responsible for following up and \
verifying the fix.
- A customer can LOG a new issue (Issues Log Issue / "Report a cleaning concern"): \
pick the facility, describe the problem, set severity, optionally attach a photo. \
Customers cannot assign issues to staff the provider triages them.
- FOLLOW an issue (the Follow button on the issue page) to get email + in-app \
notifications whenever its status changes. Customers can also comment on issues they \
reported or follow.
=== SLA (resolution targets by severity) ===
Critical = 4 hours, High = 24 hours, Medium = 72 hours, Low = 168 hours (7 days). \
These are targets measured from when the issue was reported; the system flags issues \
that are at risk of, or have passed, their SLA.
=== FACILITY QR CODES ===
Every facility has a printable QR code (from the facility's page, or "Print All QR \
Codes" on the Facilities page). Anyone can scan it — no login — to see the facility's \
recent cleaning quality and to "Report a Problem" (which files an issue). Customers can \
view, print, and regenerate their facilities' QR codes; regenerating invalidates any \
previously printed code, so it must be reprinted.
=== NOTIFICATIONS ===
Customers get in-app (bell icon) and email notifications for relevant events e.g. an \
inspection completed at their facility, or updates on issues they follow/reported. \
Notification Preferences let a customer turn specific email types off or switch to a \
digest.
=== GETTING HUMAN HELP ===
If the customer needs something this chat can't resolve — an access/login problem, a \
billing question, a specific scheduling request, or a concern that needs a person tell \
them clearly and point them to the "Submit to Support" button (top of the chat), which \
opens a request that the provider's admin team answers by email and in "My Requests".
=== STYLE & RULES ===
- Be concise, warm, and practical. Prefer short paragraphs or numbered steps.
- Ground answers in the features above. If you are not sure or the app may differ, say \
so honestly rather than guessing and suggest "Submit to Support".
- NEVER invent specific staff names, contract prices, cleaning schedules, phone numbers, \
facility data, or scores. You do not have access to the customer's live data — guide \
them to where to find it in the portal instead.
- Do not claim to perform actions yourself; explain where in the portal the customer does it.\
"""
# Preset FAQ questions shown as quick-reply chips on first load
FAQS = [
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
{'icon': 'bi-exclamation-circle','text': 'How do I track an open issue?'},
{'icon': 'bi-exclamation-circle','text': 'How do I track or follow an issue?'},
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
{'icon': 'bi-people', 'text': 'What does "Handled By" mean on an issue?'},
{'icon': 'bi-qr-code', 'text': "How do I print my facility's QR code?"},
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
]
# Soft cap on injected knowledge to keep prompt size (and token cost) reasonable.
_KB_MAX_CHARS = 6000
def _system_prompt_with_kb():
"""Return the base system prompt plus all ACTIVE admin knowledge entries
(phase38), so staff can curate the chatbot's knowledge without code changes.
Best-effort a KB failure never breaks the chat."""
prompt = _SYSTEM_PROMPT
try:
entries = (SupportKnowledge.query
.filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc())
.all())
if entries:
parts = ["\n\n=== ADDITIONAL KNOWLEDGE (curated by the JQC team; "
"treat as authoritative and prefer it over general guesses) ==="]
total = 0
for e in entries:
block = f"\n\nTopic: {e.title}\n{e.content.strip()}"
if total + len(block) > _KB_MAX_CHARS:
break
parts.append(block)
total += len(block)
prompt += ''.join(parts)
except Exception as exc:
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
return prompt
# ── Customer chat page ────────────────────────────────────────────────────────
@bp.route('/chat')
@@ -117,7 +213,7 @@ def chat_message():
from groq import Groq
client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
# Append prior conversation (cap at last 20 turns to control token usage)
for m in history[-20:]:
if m.get('role') in ('user', 'assistant') and m.get('content'):
@@ -416,6 +512,84 @@ def admin_conversation_detail(session_id):
session=session, messages=messages)
# ── Admin: AI chatbot Knowledge Base ──────────────────────────────────────────
@bp.route('/admin/knowledge')
@login_required
@supervisor_required
def admin_knowledge():
entries = (SupportKnowledge.query
.order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc())
.all())
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
return render_template('support/admin_knowledge.html',
entries=entries, groq_ready=groq_ready)
@bp.route('/admin/knowledge/new', methods=['GET', 'POST'])
@login_required
@supervisor_required
def admin_knowledge_new():
from app.utils.forms import SupportKnowledgeForm
form = SupportKnowledgeForm()
if form.validate_on_submit():
entry = SupportKnowledge(
title = form.title.data.strip(),
content = form.content.data.strip(),
sort_order = form.sort_order.data or 0,
active = form.active.data,
created_by = current_user.id,
created_at = now_eastern(),
updated_at = now_eastern(),
)
db.session.add(entry)
db.session.commit()
log_action(ACTION_CREATE, 'SupportKnowledge', entry.id, entry.title[:60])
flash('Knowledge entry added. The chatbot will use it immediately.', 'success')
return redirect(url_for('support.admin_knowledge'))
return render_template('support/admin_knowledge_form.html',
form=form, title='New Knowledge Entry')
@bp.route('/admin/knowledge/<int:entry_id>/edit', methods=['GET', 'POST'])
@login_required
@supervisor_required
def admin_knowledge_edit(entry_id):
from app.utils.forms import SupportKnowledgeForm
entry = db.session.get(SupportKnowledge, entry_id)
if entry is None:
abort(404)
form = SupportKnowledgeForm(obj=entry)
if form.validate_on_submit():
entry.title = form.title.data.strip()
entry.content = form.content.data.strip()
entry.sort_order = form.sort_order.data or 0
entry.active = form.active.data
entry.updated_at = now_eastern()
db.session.commit()
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, entry.title[:60])
flash('Knowledge entry updated.', 'success')
return redirect(url_for('support.admin_knowledge'))
return render_template('support/admin_knowledge_form.html',
form=form, title='Edit Knowledge Entry', entry=entry)
@bp.route('/admin/knowledge/<int:entry_id>/delete', methods=['POST'])
@login_required
@supervisor_required
def admin_knowledge_delete(entry_id):
entry = db.session.get(SupportKnowledge, entry_id)
if entry is None:
abort(404)
label = entry.title[:60]
eid = entry.id
db.session.delete(entry)
db.session.commit()
log_action(ACTION_DELETE, 'SupportKnowledge', eid, label)
flash('Knowledge entry deleted.', 'success')
return redirect(url_for('support.admin_knowledge'))
def _notify_customer_reply(ticket, reply):
"""Create an in-app notification and send an email to the customer."""
if not ticket.customer:
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Chatbot Knowledge Base{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h4 class="mb-0"><i class="bi bi-robot me-2 text-primary"></i>Chatbot Knowledge Base</h4>
<small class="text-muted">Curate what the AI support assistant knows about the app. Active entries are used on every chat.</small>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-life-preserver me-1"></i>Support Tickets
</a>
<a href="{{ url_for('support.admin_knowledge_new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Add Entry
</a>
</div>
</div>
{% if not groq_ready %}
<div class="alert alert-warning py-2">
<i class="bi bi-exclamation-triangle"></i>
The AI assistant is not configured (<code>GROQ_API_KEY</code> is not set), so these entries won't be used until it is enabled.
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-body p-0">
{% if entries %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:60px;">Order</th>
<th>Topic / Question</th>
<th>Answer (preview)</th>
<th class="text-center">Status</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr class="{{ '' if e.active else 'text-muted' }}">
<td>{{ e.sort_order }}</td>
<td class="fw-semibold">{{ e.title }}</td>
<td class="small text-muted text-truncate" style="max-width:360px;">
{{ e.content[:120] }}{% if e.content|length > 120 %}…{% endif %}
</td>
<td class="text-center">
{% if e.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end text-nowrap">
<a href="{{ url_for('support.admin_knowledge_edit', entry_id=e.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="POST" class="d-inline"
action="{{ url_for('support.admin_knowledge_delete', entry_id=e.id) }}"
onsubmit="return confirm('Delete this knowledge entry?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-muted text-center">
No knowledge entries yet.
<a href="{{ url_for('support.admin_knowledge_new') }}">Add your first one</a> to teach the chatbot about your app.
</div>
{% endif %}
</div>
</div>
<p class="text-muted small mt-2">
<i class="bi bi-info-circle"></i>
Tip: write each entry as a clear topic and a concise, factual answer (steps work well).
Keep entries accurate — the assistant treats them as authoritative.
</p>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
<div class="card-body">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.title.label(class="form-label fw-semibold") }}
{{ form.title(class="form-control", placeholder="e.g. How do customers reset their password?") }}
{% for e in form.title.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.content.label(class="form-label fw-semibold") }}
{{ form.content(class="form-control", rows=8,
placeholder="Write a concise, factual answer the assistant should know. Steps and specifics work best.") }}
{% for e in form.content.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text">Plain text. This is injected into the chatbot's knowledge on every conversation.</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
{{ form.sort_order.label(class="form-label fw-semibold") }}
{{ form.sort_order(class="form-control") }}
<div class="form-text">Lower numbers appear first.</div>
</div>
<div class="col-md-8 mb-3 d-flex align-items-end">
<div class="form-check">
{{ form.active(class="form-check-input") }}
{{ form.active.label(class="form-check-label") }}
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+5
View File
@@ -7,9 +7,14 @@
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>Customer Support Tickets</h4>
<small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-left-dots me-1"></i>Chat Conversations
</a>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-robot me-1"></i>Chatbot Knowledge
</a>
</div>
</div>
{# Status filter tabs #}
+9
View File
@@ -334,3 +334,12 @@ class ScheduledInspectionForm(FlaskForm):
next_due_date = DateField('Due Date', validators=[DataRequired()])
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
active = BooleanField('Active', default=True)
# ── Support Knowledge Base (phase38) ─────────────────────────────────────────
class SupportKnowledgeForm(FlaskForm):
title = StringField('Topic / Question', validators=[DataRequired(), Length(max=200)])
content = TextAreaField('Answer / Knowledge', validators=[DataRequired(), Length(max=4000)])
sort_order = IntegerField('Sort Order', validators=[Optional(), NumberRange(min=0, max=9999)], default=0)
active = BooleanField('Active (included in the chatbot)', default=True)
@@ -0,0 +1,46 @@
"""phase38 — support_knowledge (admin-curated AI chat knowledge base)
Admin-editable knowledge entries injected into the support chatbot's system
prompt so staff can improve its app knowledge without code changes.
Uses table existence check safe to re-run.
"""
revision = 'phase38_support_knowledge'
down_revision = 'phase37_support_chat'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _table_exists(conn, table):
return conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table}).scalar() > 0
def upgrade():
bind = op.get_bind()
if _table_exists(bind, 'support_knowledge'):
return
op.create_table(
'support_knowledge',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('title', sa.String(200), nullable=False),
sa.Column('content', sa.Text, nullable=False),
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
sa.Column('sort_order', sa.Integer, nullable=False, server_default='0'),
sa.Column('created_by', sa.Integer,
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
)
def downgrade():
bind = op.get_bind()
if _table_exists(bind, 'support_knowledge'):
op.drop_table('support_knowledge')