Rewrote Crabstero from Java to Python.
- Replaced Javacord with discord.py. - Replaced Redis backend with SQLite via aiosqlite. - Replaced Gradle build with pyproject.toml and uv. - Added setuptools-scm for automatic versioning from git tags. - Added argparse CLI with systemd credential support for the bot token. - Replaced per-channel ingestion tasks with a bounded queue and worker pool. - Removed Dockerfile and Gitea Actions workflow.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Ingests sentences and generates new ones using a Markov chain backed by SQLite storage.
|
||||
|
||||
The Markov chain stores word transitions per channel, using duplicate rows to represent frequency
|
||||
weight. Random selection via ORDER BY RANDOM() LIMIT 1 naturally preserves this weighting.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crabstero.database import Database
|
||||
|
||||
# Section sign (§), used internally to mark the end of sentences that lack punctuation,
|
||||
# as is common on Discord.
|
||||
DEFAULT_SENTENCE_END = "\u00a7"
|
||||
|
||||
|
||||
def is_complete_sentence(sentence: str) -> bool:
|
||||
"""
|
||||
Checks whether a given sentence ends with a default sentence end character, a period, an
|
||||
exclamation mark, or a question mark.
|
||||
|
||||
:param sentence: The sentence to test.
|
||||
:return: True if the sentence ends with a valid terminator, False otherwise.
|
||||
"""
|
||||
if not sentence:
|
||||
return False
|
||||
|
||||
return sentence[-1] in (DEFAULT_SENTENCE_END, ".", "!", "?")
|
||||
|
||||
|
||||
async def ingest(db: Database, channel_id: int, paragraph: str) -> None:
|
||||
"""
|
||||
Ingests a string potentially containing multiple smaller sentences into the Markov chain
|
||||
for a given channel.
|
||||
|
||||
:param db: The database instance.
|
||||
:param channel_id: The Discord channel ID to associate with this data.
|
||||
:param paragraph: The paragraph of sentences to ingest.
|
||||
"""
|
||||
if not is_complete_sentence(paragraph):
|
||||
paragraph += DEFAULT_SENTENCE_END
|
||||
|
||||
# Normalize whitespace, then split on sentence-ending punctuation followed by a space.
|
||||
normalized = re.sub(r" +", " ", paragraph.strip().replace("\n", " "))
|
||||
sentences = re.split(r"(?<=[.!?]) ", normalized)
|
||||
|
||||
for sentence in sentences:
|
||||
await _ingest_sentence(db, channel_id, sentence)
|
||||
|
||||
|
||||
async def _ingest_sentence(db: Database, channel_id: int, sentence: str) -> None:
|
||||
"""
|
||||
Ingests a string containing a single sentence into the Markov chain for a given channel.
|
||||
|
||||
:param db: The database instance.
|
||||
:param channel_id: The Discord channel ID to associate with this data.
|
||||
:param sentence: The sentence to ingest.
|
||||
"""
|
||||
if not is_complete_sentence(sentence):
|
||||
sentence += DEFAULT_SENTENCE_END
|
||||
|
||||
# Normalize whitespace and split into individual words.
|
||||
words = re.sub(r" +", " ", sentence.strip()).split(" ")
|
||||
|
||||
start_words: list[tuple[int, str]] = []
|
||||
transitions: list[tuple[int, str, str]] = []
|
||||
|
||||
for i in range(len(words) - 1):
|
||||
if i == 0:
|
||||
start_words.append((channel_id, words[i]))
|
||||
transitions.append((channel_id, words[i], words[i + 1]))
|
||||
|
||||
if start_words:
|
||||
await db.add_start_words_batch(start_words)
|
||||
if transitions:
|
||||
await db.add_transitions_batch(transitions)
|
||||
|
||||
|
||||
async def generate(
|
||||
db: Database, channel_id: int, soft_limit: int = 750, hard_limit: int = 1000
|
||||
) -> str:
|
||||
"""
|
||||
Generates a new sentence using words learned from previously ingested sentences for a given
|
||||
channel.
|
||||
|
||||
:param db: The database instance.
|
||||
:param channel_id: The Discord channel ID to generate from.
|
||||
:param soft_limit: The amount of characters to try and limit sentence length around.
|
||||
:param hard_limit: The amount of characters to cut off the sentence at if it gets too long.
|
||||
:return: A new generated sentence.
|
||||
"""
|
||||
word = await db.get_random_start_word(channel_id)
|
||||
|
||||
# Seed the chain with a fallback sentence if the channel has no data yet.
|
||||
if word is None:
|
||||
await _ingest_sentence(db, channel_id, "Hello world!")
|
||||
word = await db.get_random_start_word(channel_id)
|
||||
if word is None:
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
parts.append(word)
|
||||
current_length = len(word)
|
||||
|
||||
# The loop is skipped if the starting word already ends a sentence (e.g. "Yes.").
|
||||
while not is_complete_sentence(word):
|
||||
# Past the soft limit, prefer a sentence-ending word to wrap up.
|
||||
if current_length >= soft_limit:
|
||||
next_word = await db.get_random_completing_next_word(channel_id, word)
|
||||
if next_word is None:
|
||||
next_word = await db.get_random_next_word(channel_id, word)
|
||||
else:
|
||||
next_word = await db.get_random_next_word(channel_id, word)
|
||||
|
||||
if next_word is None:
|
||||
break
|
||||
|
||||
word = next_word
|
||||
|
||||
parts.append(word)
|
||||
current_length += 1 + len(word) # +1 for the joining space.
|
||||
|
||||
if current_length >= hard_limit:
|
||||
result = " ".join(parts)[:hard_limit]
|
||||
# Strip the internal sentence-end marker if it ended up at the boundary.
|
||||
if result and result[-1] == DEFAULT_SENTENCE_END:
|
||||
return result[:-1]
|
||||
return result
|
||||
|
||||
result = " ".join(parts)
|
||||
|
||||
# Strip the internal sentence-end marker so it never appears in output.
|
||||
if result and result[-1] == DEFAULT_SENTENCE_END:
|
||||
return result[:-1]
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user