From d380f34364e14e02079af33cfa23b9e2b0816e79 Mon Sep 17 00:00:00 2001 From: db0 Date: Sun, 15 Oct 2023 17:54:17 +0200 Subject: [PATCH 1/2] feat: Rebuttals --- fediseer/apis/models/v1.py | 5 + fediseer/apis/v1/__init__.py | 2 + fediseer/apis/v1/censures.py | 2 + fediseer/apis/v1/hesitations.py | 2 + fediseer/apis/v1/rebuttals.py | 162 ++++++++++++++++++++++++++++++++ fediseer/classes/instance.py | 13 +++ fediseer/database/functions.py | 36 ++++--- fediseer/enums.py | 1 + sql_statements/0.21.0.txt | 1 + 9 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 fediseer/apis/v1/rebuttals.py create mode 100644 sql_statements/0.21.0.txt diff --git a/fediseer/apis/models/v1.py b/fediseer/apis/models/v1.py index af6e205..73bf353 100644 --- a/fediseer/apis/models/v1.py +++ b/fediseer/apis/models/v1.py @@ -62,6 +62,7 @@ class Models: self.response_model_instances_censured = api.inherit('CensuredInstanceDetails', self.response_model_instances, { 'censure_reasons': fields.List(fields.String(description="The reasons instances have given for censuring this instance")), 'censure_evidence': fields.List(fields.String(description="Evidence justifying this censure, typically should be one or more URLs.")), + 'rebuttal': fields.List(fields.String(description="Counter argument by the target instance.", example="Nuh uh!")), 'censure_count': fields.Integer(description="The amount of censures this instance has received from the reference instances"), }) self.response_model_model_Censures_get = api.model('CensuredInstances', { @@ -80,6 +81,7 @@ class Models: self.response_model_dubious_instances = api.inherit('DubiousInstanceDetails', self.response_model_instances, { 'hesitation_reasons': fields.List(fields.String(description="The reasons instances have given for hesitating against this instance")), 'hesitation_evidence': fields.List(fields.String(description="Evidence justifying this hesitation, typically should be one or more URLs.")), + 'rebuttal': fields.List(fields.String(description="Counter argument by the target instance.", example="Nuh uh!")), 'hesitation_count': fields.Integer(description="The amount of hesitations this instance has received from the reference instances"), }) self.response_model_model_Hesitations_get = api.model('DubiousInstances', { @@ -94,6 +96,9 @@ class Models: 'reason': fields.String(required=False, description="The reason for this censure/hesitation. No profanity or hate speech allowed!", example="csam"), 'evidence': fields.String(required=False, description="The evidence for this censure/hesitation. Typically URL but can be a long form of anything you feel appropriate.", example="https://link.to/your/evidence", max_length=1000), }) + self.input_rebuttals_modify = api.model('ModifyRebuttals', { + 'rebuttal': fields.String(required=False, description="The counter-argument for this censure/hesitation.", example="Nuh uh!", max_length=1000), + }) self.response_model_api_key_reset = api.model('ApiKeyReset', { "message": fields.String(default='OK',required=True, description="The result of this operation."), "new_key": fields.String(default=None,required=False, description="The new API key"), diff --git a/fediseer/apis/v1/__init__.py b/fediseer/apis/v1/__init__.py index 4418997..6710616 100644 --- a/fediseer/apis/v1/__init__.py +++ b/fediseer/apis/v1/__init__.py @@ -4,6 +4,7 @@ import fediseer.apis.v1.solicitations as solicitations import fediseer.apis.v1.endorsements as endorsements import fediseer.apis.v1.censures as censures import fediseer.apis.v1.hesitations as hesitations +import fediseer.apis.v1.rebuttals as rebuttals import fediseer.apis.v1.guarantees as guarantees import fediseer.apis.v1.activitypub as activitypub import fediseer.apis.v1.badges as badges @@ -27,6 +28,7 @@ api.add_resource(endorsements.BatchEndorsements, "/batch/endorsements") api.add_resource(censures.Censures, "/censures/") api.add_resource(censures.CensuresGiven, "/censures_given/") api.add_resource(censures.BatchCensures, "/batch/censures") +api.add_resource(rebuttals.Rebuttals, "/rebuttals/") api.add_resource(hesitations.Hesitations, "/hesitations/") api.add_resource(hesitations.HesitationsGiven, "/hesitations_given/") api.add_resource(hesitations.BatchHesitations, "/batch/hesitations") diff --git a/fediseer/apis/v1/censures.py b/fediseer/apis/v1/censures.py index 220b79c..7922098 100644 --- a/fediseer/apis/v1/censures.py +++ b/fediseer/apis/v1/censures.py @@ -154,6 +154,7 @@ class Censures(Resource): if p_instance != get_instance: continue instances.append(p_instance) + rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances]) for c_instance in instances: censures = database.get_all_censure_reasons_for_censured_id(instance.id, [c_instance.id]) censures = [c for c in censures if c.reason is not None] @@ -161,6 +162,7 @@ class Censures(Resource): if len(censures) > 0: c_instance_details["censure_reasons"] = [censure.reason for censure in censures] c_instance_details["censure_evidence"] = [censure.evidence for censure in censures if censure.evidence is not None] + c_instance_details["rebuttal"] = [r.rebuttal for r in rebuttals if r.target_id == c_instance.id] instance_details.append(c_instance_details) if self.args.csv: return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 diff --git a/fediseer/apis/v1/hesitations.py b/fediseer/apis/v1/hesitations.py index 05f0563..fad3c12 100644 --- a/fediseer/apis/v1/hesitations.py +++ b/fediseer/apis/v1/hesitations.py @@ -140,6 +140,7 @@ class Hesitations(Resource): continue instances.append(p_instance) instance_details = [] + rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances]) for c_instance in instances: hesitations = database.get_all_hesitation_reasons_for_dubious_id(instance.id, [c_instance.id]) hesitations = [c for c in hesitations if c.reason is not None] @@ -147,6 +148,7 @@ class Hesitations(Resource): if len(hesitations) > 0: c_instance_details["hesitation_reasons"] = [hesitation.reason for hesitation in hesitations] c_instance_details["hesitation_evidence"] = [hesitation.evidence for hesitation in hesitations if hesitation.evidence is not None] + c_instance_details["rebuttal"] = [r.rebuttal for r in rebuttals if r.target_id == c_instance.id] instance_details.append(c_instance_details) if self.args.csv: return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 diff --git a/fediseer/apis/v1/rebuttals.py b/fediseer/apis/v1/rebuttals.py new file mode 100644 index 0000000..4dd8679 --- /dev/null +++ b/fediseer/apis/v1/rebuttals.py @@ -0,0 +1,162 @@ +from fediseer.apis.v1.base import * +from fediseer.classes.instance import Rebuttal +from fediseer.utils import sanitize_string +from fediseer.classes.reports import Report +from fediseer import enums + +class Rebuttals(Resource): + decorators = [limiter.limit("45/minute"), limiter.limit("30/minute", key_func = get_request_path)] + put_parser = reqparse.RequestParser() + put_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers') + put_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + put_parser.add_argument("rebuttal", default=None, type=str, required=True, location="json") + + + @api.expect(put_parser,models.input_rebuttals_modify, validate=True) + @api.marshal_with(models.response_model_simple_response, code=200, description='Rebut Censure or Hesitation against your instance') + @api.response(400, 'Bad Request', models.response_model_error) + @api.response(401, 'Invalid API Key', models.response_model_error) + @api.response(403, 'Access Denied', models.response_model_error) + @api.response(404, 'Instance not registered', models.response_model_error) + def put(self, domain): + '''Rebut a Censure or Hesitation against your instance + Use this to provide evidence against, or to initiate discussion + ''' + self.args = self.put_parser.parse_args() + if not self.args.apikey: + raise e.Unauthorized("You must provide the API key that was PM'd to your admin account") + instance = database.find_instance_by_api_key(self.args.apikey) + if not instance: + raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?") + if len(instance.guarantors) == 0: + raise e.Forbidden("Only guaranteed instances can rebut.") + if database.instance_has_flag(instance.id,enums.InstanceFlags.RESTRICTED): + raise e.Forbidden("You cannot take this action as your instance is restricted") + if database.has_too_many_actions_per_min(instance.domain): + raise e.TooManyRequests("Your instance is doing more than 20 actions per minute. Please slow down.") + unbroken_chain, chainbreaker = database.has_unbroken_chain(instance.id) + if not unbroken_chain: + raise e.Forbidden(f"Guarantee chain for this instance has been broken. Chain ends at {chainbreaker.domain}!") + target_instance = database.find_instance_by_domain(domain) + if not target_instance: + raise e.NotFound(f"Instance {domain} is not registered on the fediseer.") + if database.get_rebuttal(target_instance.id,instance.id): + return {"message":'OK'}, 200 + censure = database.get_censure(instance.id, target_instance.id) + if not censure or target_instance.visibility_censures != enums.ListVisibility.OPEN: + hesitation = database.get_hesitation(instance.id, target_instance.id) + if not hesitation or target_instance.visibility_hesitations != enums.ListVisibility.OPEN: + raise e.BadRequest(f"Either no censure or hesitation from {domain} found towards {instance.domain}, or they are not openly visible.") + rebuttal_value = self.args.rebuttal + if rebuttal_value is not None: + rebuttal_value = sanitize_string(rebuttal_value) + new_rebuttal = Rebuttal( + source_id=instance.id, + target_id=target_instance.id, + rebuttal=rebuttal_value, + ) + db.session.add(new_rebuttal) + target_domain = target_instance.domain + new_report = Report( + source_domain=instance.domain, + target_domain=target_domain, + report_type=enums.ReportType.REBUTTAL, + report_activity=enums.ReportActivity.ADDED, + ) + db.session.add(new_report) + db.session.commit() + logger.info(f"{instance.domain} Rebutted {domain}") + return {"message":'Changed'}, 200 + + + decorators = [limiter.limit("20/minute", key_func = get_request_path)] + patch_parser = reqparse.RequestParser() + patch_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers') + patch_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + patch_parser.add_argument("rebuttal", default=None, type=str, required=False, location="json") + + + @api.expect(patch_parser,models.input_rebuttals_modify, validate=True) + @api.marshal_with(models.response_model_simple_response, code=200, description='Modify Rebuttal') + @api.response(400, 'Bad Request', models.response_model_error) + @api.response(401, 'Invalid API Key', models.response_model_error) + @api.response(403, 'Not Guaranteed', models.response_model_error) + @api.response(404, 'Instance not registered', models.response_model_error) + def patch(self, domain): + '''Modify a Rebuttal + ''' + self.args = self.patch_parser.parse_args() + if not self.args.apikey: + raise e.Unauthorized("You must provide the API key that was PM'd to your admin account") + instance = database.find_instance_by_api_key(self.args.apikey) + if not instance: + raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?") + if database.has_too_many_actions_per_min(instance.domain): + raise e.TooManyRequests("Your instance is doing more than 20 actions per minute. Please slow down.") + target_instance = database.find_instance_by_domain(domain=domain) + if not target_instance: + raise e.BadRequest("Instance from which to modify censure not found") + rebuttal = database.get_rebuttal(target_instance.id,instance.id) + if not rebuttal: + raise e.BadRequest(f"No Rebuttal found for {domain} from {instance.domain}") + changed = False + rebuttal_value = self.args.rebuttal + if rebuttal_value is not None: + rebuttal_value = sanitize_string(rebuttal_value) + if rebuttal.rebuttal != rebuttal_value: + rebuttal.rebuttal = rebuttal_value + changed = True + if changed is False: + return {"message":'OK'}, 200 + target_domain = target_instance.domain + if instance.visibility_censures != enums.ListVisibility.OPEN: + target_domain = '[REDACTED]' + new_report = Report( + source_domain=instance.domain, + target_domain=target_domain, + report_type=enums.ReportType.REBUTTAL, + report_activity=enums.ReportActivity.MODIFIED, + ) + db.session.add(new_report) + db.session.commit() + logger.info(f"{instance.domain} modified rebuttal about {domain}") + return {"message":'Changed'}, 200 + + + decorators = [limiter.limit("20/minute", key_func = get_request_path)] + delete_parser = reqparse.RequestParser() + delete_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers') + delete_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + + @api.expect(delete_parser) + @api.marshal_with(models.response_model_simple_response, code=200, description='Delete Rebuttal') + @api.response(400, 'Bad Request', models.response_model_error) + @api.response(401, 'Invalid API Key', models.response_model_error) + @api.response(404, 'Instance not registered', models.response_model_error) + def delete(self,domain): + '''Delete a rebuttal + ''' + self.args = self.delete_parser.parse_args() + if not self.args.apikey: + raise e.Unauthorized("You must provide the API key that was PM'd to your admin account") + instance = database.find_instance_by_api_key(self.args.apikey) + if not instance: + raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?") + target_instance = database.find_instance_by_domain(domain=domain) + if not target_instance: + raise e.BadRequest("Instance from which to withdraw censure not found") + rebuttal = database.get_rebuttal(target_instance.id,instance.id) + if not rebuttal: + return {"message":'OK'}, 200 + db.session.delete(rebuttal) + target_domain = target_instance.domain + new_report = Report( + source_domain=instance.domain, + target_domain=target_domain, + report_type=enums.ReportType.REBUTTAL, + report_activity=enums.ReportActivity.DELETED, + ) + db.session.add(new_report) + db.session.commit() + logger.info(f"{instance.domain} delete rebuttal about {domain}") + return {"message":'Changed'}, 200 diff --git a/fediseer/classes/instance.py b/fediseer/classes/instance.py index 47f02eb..39b26b7 100644 --- a/fediseer/classes/instance.py +++ b/fediseer/classes/instance.py @@ -72,6 +72,17 @@ class Hesitation(db.Model): dubious_instance = db.relationship("Instance", back_populates="hesitations_received", foreign_keys=[dubious_id]) created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class Rebuttal(db.Model): + __tablename__ = "rebuttals" + __table_args__ = (UniqueConstraint('source_id', 'target_id', name='rebuttal_source_id_target_id'),) + id = db.Column(db.Integer, primary_key=True) + rebuttal = db.Column(db.Text, unique=False, nullable=False, index=False) + source_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True) + source_instance = db.relationship("Instance", back_populates="rebuttals_given", foreign_keys=[source_id]) + target_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True) + target_instance = db.relationship("Instance", back_populates="rebuttals_received", foreign_keys=[target_id]) + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + class Solicitation(db.Model): __tablename__ = "solicitations" __table_args__ = (UniqueConstraint('source_id', 'target_id', name='solicitations_source_id_target_id'),) @@ -134,6 +145,8 @@ class Instance(db.Model): censures_received = db.relationship("Censure", back_populates="censured_instance", cascade="all, delete-orphan", foreign_keys=[Censure.censured_id]) hesitations_given = db.relationship("Hesitation", back_populates="hesitating_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.hesitant_id]) hesitations_received = db.relationship("Hesitation", back_populates="dubious_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.dubious_id]) + rebuttals_given = db.relationship("Rebuttal", back_populates="source_instance", cascade="all, delete-orphan", foreign_keys=[Rebuttal.source_id]) + rebuttals_received = db.relationship("Rebuttal", back_populates="target_instance", cascade="all, delete-orphan", foreign_keys=[Rebuttal.target_id]) solicitations_requested = db.relationship("Solicitation", back_populates="source_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.source_id]) solicitations_received = db.relationship("Solicitation", back_populates="target_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.target_id]) guarantees = db.relationship("Guarantee", back_populates="guarantor_instance", cascade="all, delete-orphan", foreign_keys=[Guarantee.guarantor_id]) diff --git a/fediseer/database/functions.py b/fediseer/database/functions.py index 0557c15..9439a2e 100644 --- a/fediseer/database/functions.py +++ b/fediseer/database/functions.py @@ -4,7 +4,7 @@ from sqlalchemy import func, or_, and_, not_, Boolean from fediseer.flask import db from fediseer.utils import hash_api_key from sqlalchemy.orm import joinedload -from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation, InstanceFlag, InstanceTag +from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation, InstanceFlag, InstanceTag, Rebuttal from fediseer.classes.user import Claim, User from fediseer.classes.reports import Report from fediseer import enums @@ -93,10 +93,8 @@ def get_all_approving_instances_by_endorsed_id(endorsed_id): def get_all_endorsement_reasons_for_endorsed_id(endorsed_id, approving_ids): query = Endorsement.query.filter( - and_( - Endorsement.endorsed_id == endorsed_id, - Endorsement.approving_id.in_(approving_ids), - ) + Endorsement.endorsed_id == endorsed_id, + Endorsement.approving_id.in_(approving_ids), ).with_entities( Endorsement.reason, ) @@ -146,10 +144,8 @@ def get_all_censuring_instances_by_censured_id(censured_id): def get_all_censure_reasons_for_censured_id(censured_id, censuring_ids): query = Censure.query.filter( - and_( - Censure.censured_id == censured_id, - Censure.censuring_id.in_(censuring_ids), - ) + Censure.censured_id == censured_id, + Censure.censuring_id.in_(censuring_ids), ).with_entities( Censure.reason, Censure.evidence, @@ -200,17 +196,31 @@ def get_all_hesitant_instances_by_dubious_id(dubious_id): def get_all_hesitation_reasons_for_dubious_id(dubious_id, hesitant_ids): query = Hesitation.query.filter( - and_( - Hesitation.dubious_id == dubious_id, - Hesitation.hesitant_id.in_(hesitant_ids), - ) + Hesitation.dubious_id == dubious_id, + Hesitation.hesitant_id.in_(hesitant_ids), ).with_entities( Hesitation.reason, Hesitation.evidence, ) return query.all() +def get_rebuttal(target_instance_id, source_instance_id): + query = Rebuttal.query.filter_by( + target_id=target_instance_id, + source_id=source_instance_id, + ) + return query.first() +def get_all_rebuttals_from_source_instance_id(source_instance_id, target_ids = None): + query = Rebuttal.query.filter( + Rebuttal.source_id == source_instance_id, + ).with_entities( + Rebuttal.rebuttal, + Rebuttal.target_id, + ) + if target_ids is not None: + query = query.filter(Rebuttal.target_id.in_(target_ids)) + return query.all() def get_all_guaranteed_instances_by_guarantor_id(guarantor_id): query = db.session.query( diff --git a/fediseer/enums.py b/fediseer/enums.py index e4d1961..b301654 100644 --- a/fediseer/enums.py +++ b/fediseer/enums.py @@ -8,6 +8,7 @@ class ReportType(enum.Enum): CLAIM = 4 SOLICITATION = 5 FLAG = 6 + REBUTTAL = 7 class ReportActivity(enum.Enum): ADDED = 0 diff --git a/sql_statements/0.21.0.txt b/sql_statements/0.21.0.txt new file mode 100644 index 0000000..9f737c1 --- /dev/null +++ b/sql_statements/0.21.0.txt @@ -0,0 +1 @@ +ALTER TYPE reporttype ADD VALUE 'REBUTTAL'; From b44529242d4d3ca6ef85de6ee5087f06f51bc923 Mon Sep 17 00:00:00 2001 From: db0 Date: Sun, 15 Oct 2023 19:18:06 +0200 Subject: [PATCH 2/2] fix: Increase speed on censures and hesitations GET --- CHANGELOG.md | 5 +++++ fediseer/apis/v1/censures.py | 4 ++-- fediseer/apis/v1/hesitations.py | 4 ++-- fediseer/consts.py | 2 +- fediseer/database/functions.py | 2 ++ 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc0eb1..ae4f2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +# 0.21.0 + +* Added rebuttals +* Improved speed of GET on /hesitations and /censures + # 0.20.1 * Allow filtering by software diff --git a/fediseer/apis/v1/censures.py b/fediseer/apis/v1/censures.py index 7922098..7b2bc11 100644 --- a/fediseer/apis/v1/censures.py +++ b/fediseer/apis/v1/censures.py @@ -154,10 +154,10 @@ class Censures(Resource): if p_instance != get_instance: continue instances.append(p_instance) + censures = database.get_all_censure_reasons_for_censured_id(instance.id, [c.id for c in instances]) rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances]) for c_instance in instances: - censures = database.get_all_censure_reasons_for_censured_id(instance.id, [c_instance.id]) - censures = [c for c in censures if c.reason is not None] + censures = [c for c in censures if c.reason is not None and c.censuring_id == c_instance.id] c_instance_details = c_instance.get_details() if len(censures) > 0: c_instance_details["censure_reasons"] = [censure.reason for censure in censures] diff --git a/fediseer/apis/v1/hesitations.py b/fediseer/apis/v1/hesitations.py index fad3c12..827f550 100644 --- a/fediseer/apis/v1/hesitations.py +++ b/fediseer/apis/v1/hesitations.py @@ -140,10 +140,10 @@ class Hesitations(Resource): continue instances.append(p_instance) instance_details = [] + hesitations = database.get_all_hesitation_reasons_for_dubious_id(instance.id, [c.id for c in instances]) rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances]) for c_instance in instances: - hesitations = database.get_all_hesitation_reasons_for_dubious_id(instance.id, [c_instance.id]) - hesitations = [c for c in hesitations if c.reason is not None] + hesitations = [c for c in hesitations if c.reason is not None and c.hesitant_id == c_instance.id] c_instance_details = c_instance.get_details() if len(hesitations) > 0: c_instance_details["hesitation_reasons"] = [hesitation.reason for hesitation in hesitations] diff --git a/fediseer/consts.py b/fediseer/consts.py index a37d612..8b31a4b 100644 --- a/fediseer/consts.py +++ b/fediseer/consts.py @@ -1,4 +1,4 @@ -FEDISEER_VERSION = "0.20.0" +FEDISEER_VERSION = "0.22.0" SUPPORTED_SOFTWARE = { "lemmy", "mastodon", diff --git a/fediseer/database/functions.py b/fediseer/database/functions.py index 9439a2e..784c106 100644 --- a/fediseer/database/functions.py +++ b/fediseer/database/functions.py @@ -147,6 +147,7 @@ def get_all_censure_reasons_for_censured_id(censured_id, censuring_ids): Censure.censured_id == censured_id, Censure.censuring_id.in_(censuring_ids), ).with_entities( + Censure.censuring_id, Censure.reason, Censure.evidence, ) @@ -199,6 +200,7 @@ def get_all_hesitation_reasons_for_dubious_id(dubious_id, hesitant_ids): Hesitation.dubious_id == dubious_id, Hesitation.hesitant_id.in_(hesitant_ids), ).with_entities( + Hesitation.hesitant_id, Hesitation.reason, Hesitation.evidence, )